diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt index 89851458ac..a6275955a1 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -1,8 +1,6 @@ package com.tangem.tap.common.redux -import com.tangem.tap.common.redux.global.GlobalMiddleware import com.tangem.tap.common.redux.global.GlobalState -import com.tangem.tap.common.redux.legacy.LegacyMiddleware import com.tangem.tap.features.details.redux.DetailsMiddleware import com.tangem.tap.features.details.redux.DetailsState import com.tangem.tap.proxy.redux.DaggerGraphMiddleware @@ -20,12 +18,10 @@ data class AppState( fun getMiddleware(): List> { return listOf( logMiddleware, - GlobalMiddleware.handler, DetailsMiddleware().detailsMiddleware, LockUserWalletsTimerMiddleware().middleware, AccessCodeRequestPolicyMiddleware().middleware, DaggerGraphMiddleware.daggerGraphMiddleware, - LegacyMiddleware.legacyMiddleware, ) } } diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt index a36bd807b6..c446ff5206 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt @@ -1,6 +1,5 @@ package com.tangem.tap.common.redux.global -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.scan.ScanResponse import org.rekotlin.Action @@ -8,10 +7,5 @@ sealed class GlobalAction : Action { data class SaveScanResponse(val scanResponse: ScanResponse) : GlobalAction() - data class ChangeAppCurrency(val appCurrency: AppCurrency) : GlobalAction() - object RestoreAppCurrency : GlobalAction() { - data class Success(val appCurrency: AppCurrency) : GlobalAction() - } - data class IsSignWithRing(val isSignWithRing: Boolean) : GlobalAction() } \ No newline at end of file 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 deleted file mode 100644 index f2df3df6e9..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.domain.appcurrency.model.AppCurrency -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.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Middleware - -object GlobalMiddleware { - val handler = globalMiddlewareHandler -} - -private val globalMiddlewareHandler: Middleware = { _, _ -> - { nextDispatch -> - { action -> - handleAction(action) - nextDispatch(action) - } - } -} - -private fun handleAction(action: Action) { - when (action) { - is GlobalAction.RestoreAppCurrency -> restoreAppCurrency() - } -} - -private fun restoreAppCurrency() { - scope.launch { - val currency = store.inject(DaggerGraphState::appCurrencyRepository) - .getSelectedAppCurrency() - .firstOrNull() - ?: AppCurrency.Default - - store.dispatchWithMain(GlobalAction.RestoreAppCurrency.Success(currency)) - } -} \ No newline at end of file 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 e46630172e..32164696dc 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 @@ -13,13 +13,6 @@ fun globalReducer(action: Action, state: AppState): GlobalState { is GlobalAction.SaveScanResponse -> { globalState.copy(scanResponse = action.scanResponse) } - is GlobalAction.ChangeAppCurrency -> { - globalState.copy(appCurrency = action.appCurrency) - } - is GlobalAction.RestoreAppCurrency.Success -> { - globalState.copy(appCurrency = action.appCurrency) - } is GlobalAction.IsSignWithRing -> globalState.copy(isLastSignWithRing = action.isSignWithRing) - else -> globalState } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt index 01782e2e9a..843d9f2977 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt @@ -1,6 +1,5 @@ package com.tangem.tap.common.redux.global -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.scan.ScanResponse import com.tangem.tap.domain.TapWalletManager import org.rekotlin.StateType @@ -9,7 +8,6 @@ data class GlobalState( @Deprecated("Use scan response from selected user wallet") val scanResponse: ScanResponse? = null, val tapWalletManager: TapWalletManager = TapWalletManager(), - val appCurrency: AppCurrency = AppCurrency.Default, val isLastSignWithRing: Boolean = false, ) : StateType diff --git a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt deleted file mode 100644 index aee323386c..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.tangem.tap.common.redux.legacy - -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.redux.LegacyAction -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.features.details.redux.AppSettingsState -import com.tangem.tap.features.details.redux.DetailsAction -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.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import org.rekotlin.Middleware - -@Suppress("MemberNameEqualsClassName") -internal object LegacyMiddleware { - private val prepareDetailsScreenJobHolder = JobHolder() - - val legacyMiddleware: Middleware = { _, _ -> - { next -> - { action -> - when (action) { - is LegacyAction.PrepareDetailsScreen -> { - selectedUserWallet() - .distinctUntilChanged { old, new -> - if (old is UserWallet.Cold && new is UserWallet.Cold) { - old.walletId == new.walletId && - old.scanResponse == new.scanResponse - } else { - old.walletId == new.walletId - } - } - .onEach { selectedUserWallet -> - val initializedAppSettingsStateContent = initializeAppSettingsState() - store.dispatchWithMain( - DetailsAction.PrepareScreen( - scanResponse = (selectedUserWallet as? UserWallet.Cold)?.scanResponse, - initializedAppSettingsState = initializedAppSettingsStateContent, - ), - ) - } - .flowOn(Dispatchers.IO) - .launchIn(scope) - .saveIn(prepareDetailsScreenJobHolder) - } - } - next(action) - } - } - } - - private fun selectedUserWallet(): Flow { - return store.inject(DaggerGraphState::userWalletsListRepository).selectedUserWallet.filterNotNull() - } - - /** - * LEGACY: We need to initialize [AppSettingsState] async to avoid drawing blocking - * previously it was initialized in runBlocking and blocked details screen - */ - private suspend fun initializeAppSettingsState(): AppSettingsState { - return AppSettingsState( - selectedAppCurrency = store.state.globalState.appCurrency, - selectedThemeMode = store.inject(DaggerGraphState::appThemeModeRepository).getAppThemeMode().firstOrNull() - ?: AppThemeMode.DEFAULT, - requireAccessCode = store.inject(DaggerGraphState::walletsRepository).requireAccessCode(), - useBiometricAuthentication = store.inject(DaggerGraphState::walletsRepository).useBiometricAuthentication(), - isHidingEnabled = store.inject(DaggerGraphState::balanceHidingRepository) - .getBalanceHidingSettings().isHidingEnabledInSettings, - needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, - hasSecuredWallets = store.inject(DaggerGraphState::userWalletsListRepository).hasSecuredWallets(), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt new file mode 100644 index 0000000000..3b9ec07528 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt @@ -0,0 +1,78 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase +import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase +import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase +import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.derivations.DerivationsRepository +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 DynamicAddressesDomainModule { + + @Provides + @Singleton + fun provideEnableDynamicAddressesUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): EnableDynamicAddressesUseCase { + return EnableDynamicAddressesUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideDisableDynamicAddressesUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): DisableDynamicAddressesUseCase { + return DisableDynamicAddressesUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideGetDynamicAddressesStatusUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): GetDynamicAddressesStatusUseCase { + return GetDynamicAddressesStatusUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideGetDynamicReceiveAddressUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): GetDynamicReceiveAddressUseCase { + return GetDynamicReceiveAddressUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideCreateConsolidationTransactionUseCase( + consolidationRepository: ConsolidationRepository, + ): CreateConsolidationTransactionUseCase { + return CreateConsolidationTransactionUseCase(consolidationRepository) + } + + @Provides + @Singleton + fun provideIsXpubSupportedUseCase(walletManagersFacade: WalletManagersFacade): IsXpubSupportedUseCase { + return IsXpubSupportedUseCase(walletManagersFacade) + } + + @Provides + @Singleton + fun provideIsXpubDerivedUseCase( + walletManagersFacade: WalletManagersFacade, + derivationsRepository: DerivationsRepository, + ): IsXpubDerivedUseCase { + return IsXpubDerivedUseCase(walletManagersFacade, derivationsRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index a823079f33..5633c8744b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.staking.* import com.tangem.domain.staking.repositories.* +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.single.SingleStakingBalanceFetcher import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase import com.tangem.domain.walletmanager.WalletManagersFacade @@ -226,8 +227,14 @@ internal object StakingDomainModule { @Provides @Singleton - fun provideStakingIdFactory(walletManagersFacade: WalletManagersFacade): StakingIdFactory { - return StakingIdFactory(walletManagersFacade = walletManagersFacade) + fun provideStakingIdFactory( + walletManagersFacade: WalletManagersFacade, + stakingFeatureToggles: StakingFeatureToggles, + ): StakingIdFactory { + return StakingIdFactory( + walletManagersFacade = walletManagersFacade, + stakingFeatureToggles = stakingFeatureToggles, + ) } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt index cc707e1fce..548fadc6ad 100644 --- a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt @@ -33,7 +33,7 @@ class FinalizeTwinTask( visaCardScanHandler = null, visaCoroutineScope = null, shouldCheckIsAlreadyActivated = false, - isDynamicAddressesEnabled = false, + isDynamicAddressesEnabled = isDynamicAddressesEnabled, onboardingV2FeatureToggles = null, ).run(session, callback) is CompletionResult.Failure -> diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt index 0c647de6c4..072d738717 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt @@ -2,18 +2,12 @@ package com.tangem.tap.features.details.redux import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.scan.ScanResponse import kotlinx.coroutines.CoroutineScope import org.rekotlin.Action @Suppress("BooleanPropertyNaming") sealed class DetailsAction : Action { - data class PrepareScreen( - val scanResponse: ScanResponse?, - val initializedAppSettingsState: AppSettingsState, - ) : DetailsAction() - sealed class AppSettings : DetailsAction() { data class SwitchPrivacySetting( val enable: Boolean, @@ -50,6 +44,4 @@ sealed class DetailsAction : Action { data class Prepare(val state: AppSettingsState) : AppSettings() } - - data class ChangeAppCurrency(val currency: AppCurrency) : DetailsAction() } \ No newline at end of file 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 724134783f..fa438bf186 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 @@ -12,7 +12,6 @@ import com.tangem.tap.common.analytics.events.Settings 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.features.demo.DemoHelper import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope @@ -78,10 +77,7 @@ class DetailsMiddleware { is DetailsAction.AppSettings.ChangeBalanceHiding -> { changeBalanceHiding(action.shouldHideBalance) } - is DetailsAction.AppSettings.ChangeAppCurrency -> { - store.dispatch(GlobalAction.ChangeAppCurrency(action.currency)) - store.dispatch(DetailsAction.ChangeAppCurrency(action.currency)) - } + is DetailsAction.AppSettings.ChangeAppCurrency, is DetailsAction.AppSettings.SwitchPrivacySetting.Success, is DetailsAction.AppSettings.SwitchPrivacySetting.Failure, is DetailsAction.AppSettings.BiometricsStatusChanged, 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 a01428dbfd..40549d69f4 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 @@ -12,27 +12,12 @@ private fun internalReduce(action: Action, state: AppState): DetailsState { if (action !is DetailsAction) return state.detailsState val detailsState = state.detailsState return when (action) { - is DetailsAction.PrepareScreen -> { - handlePrepareScreen(action) - } is DetailsAction.AppSettings -> { handlePrivacyAction(action, detailsState) } - is DetailsAction.ChangeAppCurrency -> detailsState.copy( - appSettingsState = detailsState.appSettingsState.copy( - selectedAppCurrency = action.currency, - ), - ) } } -private fun handlePrepareScreen(action: DetailsAction.PrepareScreen): DetailsState { - return DetailsState( - scanResponse = action.scanResponse, - appSettingsState = action.initializedAppSettingsState, - ) -} - @Suppress("LongMethod", "CyclomaticComplexMethod") private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: DetailsState): DetailsState { return when (action) { @@ -94,6 +79,7 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail useBiometricAuthentication = action.state.useBiometricAuthentication, requireAccessCode = action.state.requireAccessCode, hasSecuredWallets = action.state.hasSecuredWallets, + needEnrollBiometrics = action.state.needEnrollBiometrics, ), ) is DetailsAction.AppSettings.EnrollBiometrics, diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt index 83cf685304..e209c707e4 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt @@ -2,12 +2,9 @@ package com.tangem.tap.features.details.redux import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.scan.ScanResponse import org.rekotlin.StateType data class DetailsState( - @Deprecated("Delete after onboarding refactoring") - val scanResponse: ScanResponse? = null, val appSettingsState: AppSettingsState = AppSettingsState(), ) : StateType diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt index 3f7613c86c..e65c6560da 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt @@ -14,6 +14,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.repository.AppThemeModeRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.sdk.api.TangemSdkManager import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings @@ -54,6 +55,7 @@ internal class AppSettingsModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val appThemeModeRepository: AppThemeModeRepository, private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender, + private val tangemSdkManager: TangemSdkManager, private val uiMessageSender: UiMessageSender, ) : Model(), StoreSubscriber { @@ -251,9 +253,8 @@ internal class AppSettingsModel @Inject constructor( private fun bootstrapAppCurrencyUpdates() { appCurrencyRepository .getSelectedAppCurrency() + .distinctUntilChanged() .onEach { appCurrency -> - if (appCurrency.code == store.state.globalState.appCurrency.code) return@onEach - store.dispatchWithMain(DetailsAction.AppSettings.ChangeAppCurrency(appCurrency)) } .launchIn(scope) @@ -267,6 +268,7 @@ internal class AppSettingsModel @Inject constructor( isHidingEnabled = balanceHidingRepository.getBalanceHidingSettings().isHidingEnabledInSettings, selectedAppCurrency = appCurrencyRepository.getSelectedAppCurrency().firstOrNull() ?: AppCurrency.Default, selectedThemeMode = appThemeModeRepository.getAppThemeMode().firstOrNull() ?: AppThemeMode.DEFAULT, + needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(), ) diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt index 1955e4b9a0..9780c3831c 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -27,6 +27,8 @@ class AccountCryptoPortfolioItemStateConverter( private val priceChangeLce: Lce? = null, private val onItemClick: ((Account.CryptoPortfolio) -> Unit)? = null, private val onItemLongClick: ((Account.CryptoPortfolio) -> Unit)? = null, + private val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?)? = null, + private val subtitle2StateProvider: ((Lce) -> Subtitle2State?)? = null, ) : Converter { override fun convert(value: TotalFiatBalance): TokenItemState { @@ -40,11 +42,11 @@ class AccountCryptoPortfolioItemStateConverter( private fun Account.CryptoPortfolio.mapToContentState( fiatBalance: TotalFiatBalance.Loaded, ): TokenItemState.Content { - val subtitle2State = priceChangeLce?.fold( - ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, - ifError = { null }, - ifContent = { priceChange -> priceChange.toSubtitle2State() }, - ) + val subtitle2State = if (priceChangeLce != null && subtitle2StateProvider != null) { + subtitle2StateProvider(priceChangeLce) + } else { + createSubtitle2State(priceChangeLce) + } return TokenItemState.Content( id = account.accountId.toItemId(), iconState = AccountIconItemStateConverter().convert(this), @@ -59,11 +61,8 @@ class AccountCryptoPortfolioItemStateConverter( ), isAvailable = false, ), - fiatAmountState = FiatAmountState.Content( - text = fiatBalance.amount - .format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, - isFlickering = fiatBalance.source == StatusSource.CACHE, - ), + fiatAmountState = fiatAmountStateProvider?.invoke(fiatBalance) + ?: createFiatAmountState(fiatBalance, appCurrency), subtitle2State = subtitle2State, onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, onItemLongClick = onItemLongClick?.let { onItemLongClick -> { onItemLongClick(account) } }, @@ -127,4 +126,30 @@ class AccountCryptoPortfolioItemStateConverter( type = this.value.getPriceChangeType(), isFlickering = this.source.isFlickering(), ) + + private fun createFiatAmountState(fiatBalance: TotalFiatBalance, appCurrency: AppCurrency): FiatAmountState { + return when (fiatBalance) { + TotalFiatBalance.Failed, + TotalFiatBalance.Loading, + -> FiatAmountState.Empty + + is TotalFiatBalance.Loaded -> FiatAmountState.Content( + text = fiatBalance.amount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + isFlickering = fiatBalance.source == StatusSource.CACHE, + ) + } + } + + private fun createSubtitle2State(priceChangeLce: Lce?): Subtitle2State? { + return priceChangeLce?.fold( + ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, + ifError = { null }, + ifContent = { priceChange -> priceChange.toSubtitle2State() }, + ) + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt index 8b1bebbcd4..182696bedf 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt @@ -1,8 +1,10 @@ package com.tangem.common.ui.navigationButtons import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference +@Immutable sealed class NavigationButtonsState { data object Empty : NavigationButtonsState() diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt index 27dceeec39..5927f3e422 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.painterResource import com.tangem.core.ui.R import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountResIcon +import com.tangem.core.ui.components.account.PaymentAccountIcon import com.tangem.core.ui.components.currency.DefaultCurrencyIcon import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @@ -61,6 +62,7 @@ internal fun ContentIcon( background = icon.background, alpha = alpha, ) + is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(modifier = modifier, size = icon.size) is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( modifier = modifier, resId = icon.resId, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index 9ec3a73b8d..f5cb062474 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -62,6 +62,7 @@ fun CurrencyIcon( is CurrencyIconState.FiatIcon, is CurrencyIconState.CustomTokenIcon, is CurrencyIconState.TokenIcon, + is CurrencyIconState.PaymentAccount, is CurrencyIconState.CryptoPortfolio.Icon, is CurrencyIconState.CryptoPortfolio.Letter, -> { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt index 5bdd5f41f4..505e0d5b67 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt @@ -88,6 +88,12 @@ sealed class CurrencyIconState { override val topBadgeIconResId: Int? = null } + data class PaymentAccount(val size: AccountIconSize = AccountIconSize.Default) : CurrencyIconState() { + override val isGrayscale: Boolean = false + override val shouldShowCustomBadge: Boolean = false + override val topBadgeIconResId: Int? = null + } + @Immutable sealed class CryptoPortfolio : CurrencyIconState() { override val shouldShowCustomBadge: Boolean = false @@ -155,6 +161,7 @@ sealed class CurrencyIconState { is CryptoPortfolio.Letter -> copy( isGrayscale = isGrayscale, ) + is PaymentAccount, is Loading, is Locked, is Empty, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt index 158c768fe8..6050d96cb3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt @@ -9,4 +9,5 @@ data class SearchBarUM( val isActive: Boolean, val onActiveChange: (Boolean) -> Unit, val onClearClick: () -> Unit = {}, + val onCancelClick: (() -> Unit)? = null, ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt index 0c6291b831..7ae2238fb7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt @@ -2,7 +2,8 @@ package com.tangem.core.ui.components.tokenlist import androidx.compose.animation.* import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds -import androidx.compose.animation.core.* +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon @@ -24,6 +25,7 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.account.AccountResIcon +import com.tangem.core.ui.components.account.PaymentAccountIcon import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.SearchBar @@ -98,6 +100,9 @@ fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolea icon.copy( size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default, ) + is CurrencyIconState.PaymentAccount -> icon.copy( + size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default, + ) else -> icon } @@ -183,7 +188,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B } } -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun ExpandedPortfolioHeader( state: TokenItemState, @@ -209,6 +214,7 @@ fun ExpandedPortfolioHeader( composables.icon.invoke(Modifier) } else { when (val icon = state.iconState) { + is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(size = icon.size) is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( resId = icon.resId, color = icon.color, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt index c09e64697b..f5c2277c35 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt @@ -55,6 +55,12 @@ sealed interface TokensListItemUM { is PortfolioItemContentUM.Tokens -> content.tokens is PortfolioItemContentUM.Empty -> persistentListOf() } + + val tokensItemsList: List + get() = when (content) { + is PortfolioItemContentUM.Tokens -> content.tokens.filterIsInstance() + is PortfolioItemContentUM.Empty -> emptyList() + } } data class Text(override val id: Any, val text: TextReference) : TokensListItemUM diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt index 4e2e9ebb42..905ef8c5fb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt @@ -289,8 +289,13 @@ private fun CancelButton( state.onQueryChange("") } keyboardController?.hide() - state.onClearClick() - focusManager.clearFocus() + val onCancel = state.onCancelClick + if (onCancel != null) { + onCancel() + } else { + state.onClearClick() + focusManager.clearFocus() + } }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt index 79f093e351..e3750fd53e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt @@ -169,6 +169,7 @@ private fun BoxScope.CurrencyIconBackgroundLayer(state: CurrencyIconState, blurR blurRadius = blurRadius, ) } + is CurrencyIconState.PaymentAccount -> Unit CurrencyIconState.Loading -> Unit CurrencyIconState.Locked -> Unit } @@ -193,7 +194,7 @@ private fun BoxScope.UrlBackground(imageUrl: String?, blurRadius: Dp) { AsyncImage( model = imageRequest, contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.FillBounds, modifier = Modifier .matchParentSize() .scale(SCALE_FACTOR) @@ -207,7 +208,7 @@ private fun BoxScope.ResBackground(res: Int, blurRadius: Dp) { Image( painter = painterResource(res), contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.FillBounds, modifier = Modifier .matchParentSize() .scale(SCALE_FACTOR) diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml new file mode 100644 index 0000000000..fac7eb89dd --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml new file mode 100644 index 0000000000..a1e00c61f2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml new file mode 100644 index 0000000000..57afa69fb6 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml new file mode 100644 index 0000000000..71ad60eda8 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/data/dynamic-addresses/build.gradle.kts b/data/dynamic-addresses/build.gradle.kts index 47a15f3e68..a504298e11 100644 --- a/data/dynamic-addresses/build.gradle.kts +++ b/data/dynamic-addresses/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { // region Project - Libs implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + implementation(tangemDeps.card.core) // endregion // region DI diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index 28c3c017b6..a9d2bcd8b7 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -1,5 +1,6 @@ package com.tangem.data.dynamicaddresses +import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.datasource.api.tangemTech.models.UserTokensResponse @@ -10,6 +11,7 @@ import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.blockchain.extensions.SimpleResult import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger @@ -31,17 +33,20 @@ internal class DefaultDynamicAddressesRepository( .map { response -> val token = response.findToken(network) when { - token?.dynamicAddressesEnabled == true -> DynamicAddressesStatus.ENABLED - else -> DynamicAddressesStatus.DISABLED + token?.dynamicAddressesEnabled != true -> DynamicAddressesStatus.DISABLED + !isXpubAvailable(userWalletId, network) -> DynamicAddressesStatus.ENABLED_REQUIRES_SETUP + else -> DynamicAddressesStatus.ENABLED } - // TODO handle ENABLED_REQUIRES_SETUP when XPUB is not derived locally } .flowOn(dispatchers.io) } override suspend fun enable(userWalletId: UserWalletId, network: Network, xpub: String) { withContext(dispatchers.io) { - walletManagersFacade.enableXpubMode(userWalletId, network, xpub) + val result = walletManagersFacade.enableXpubMode(userWalletId, network, xpub) + if (result is SimpleResult.Failure) { + error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}") + } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } .onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) } @@ -50,7 +55,10 @@ internal class DefaultDynamicAddressesRepository( override suspend fun disable(userWalletId: UserWalletId, network: Network) { withContext(dispatchers.io) { - walletManagersFacade.disableXpubMode(userWalletId, network) + val result = walletManagersFacade.disableXpubMode(userWalletId, network) + if (result is SimpleResult.Failure) { + error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}") + } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } .onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) } @@ -70,6 +78,45 @@ internal class DefaultDynamicAddressesRepository( return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network) } + override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean { + return withContext(dispatchers.io) { + val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false + val baseDerivationPath = network.derivationPath.value ?: return@withContext false + + response.accounts + .flatMap { it.tokens.orEmpty() } + .any { token -> + val tokenDerivationPath = token.derivationPath ?: return@any false + token.networkId == network.backendId && + tokenDerivationPath != baseDerivationPath && + hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath) + } + } + } + + /** + * Checks if the token's derivation path has the same first 3 nodes (purpose/coin/account) + * as the base path but different change/index nodes (not both 0). + */ + private fun hasNonZeroChangeOrIndex(tokenPath: String, basePath: String): Boolean { + val tokenNodes = runCatching { DerivationPath(tokenPath).nodes }.getOrNull() ?: return false + val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false + + if (tokenNodes.size < DERIVATION_NODE_COUNT || baseNodes.size < DERIVATION_NODE_COUNT) return false + + // First 3 nodes must match (purpose/coin/account) by value, ignoring hardening + val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i -> + tokenNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false) + } + if (!isSameAccount) return false + + // Check if change or index ≠ 0 + val change = tokenNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false) + val index = tokenNodes[INDEX_NODE_INDEX].getIndex(includeHardened = false) + + return change != 0L || index != 0L + } + private suspend fun updateTokenDynamicAddressesFlag( userWalletId: UserWalletId, network: Network, @@ -92,6 +139,11 @@ internal class DefaultDynamicAddressesRepository( } } + private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean { + // Check if WalletManager is already in XPUB mode (DA was previously enabled on this device) + return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null + } + private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? { return accounts .flatMap { it.tokens.orEmpty() } @@ -103,4 +155,11 @@ internal class DefaultDynamicAddressesRepository( derivationPath == network.derivationPath.value && contractAddress == null } + + private companion object { + const val DERIVATION_NODE_COUNT = 5 + const val ACCOUNT_NODE_COUNT = 3 + const val CHANGE_NODE_INDEX = 3 + const val INDEX_NODE_INDEX = 4 + } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index b189675a38..1403dcdaf9 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -22,6 +22,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -65,7 +66,7 @@ internal class DefaultP2PEthPoolRepository( } override suspend fun fetchVaults(network: P2PEthPoolNetwork) { - val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { + val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) { getVaults(network).getOrElse { error -> TangemLogger.e("Error fetching P2PEthPool vaults: $error") emptyList() diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index b46d6ea594..f98c982fa6 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -1,8 +1,6 @@ package com.tangem.data.staking import arrow.core.getOrElse -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.card.common.TapWorkarounds.isWallet2 import com.tangem.domain.models.currency.CryptoCurrency @@ -22,14 +20,13 @@ import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flowOf + import kotlinx.coroutines.withContext -@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class DefaultStakingRepository( private val stakeKitRepository: StakeKitRepository, private val p2pEthPoolRepository: P2PEthPoolRepository, - private val stakingBalanceStoreV2: StakeKitBalancesStore, + private val stakeKitBalancesStore: StakeKitBalancesStore, private val dispatchers: CoroutineDispatcherProvider, private val getUserWalletUseCase: GetUserWalletUseCase, private val stakingFeatureToggles: StakingFeatureToggles, @@ -40,7 +37,8 @@ internal class DefaultStakingRepository( cryptoCurrency: CryptoCurrency, ): Flow { return channelFlow { - if (!checkFeatureToggleEnabled(cryptoCurrency)) { + val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) + if (stakingIntegration == null || !stakingFeatureToggles.isIntegrationEnabled(stakingIntegration)) { send(StakingAvailability.Unavailable) return@channelFlow } @@ -56,8 +54,6 @@ internal class DefaultStakingRepository( return@channelFlow } - val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) - val availabilityFlow = when (stakingIntegration) { StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability() is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability( @@ -65,7 +61,6 @@ internal class DefaultStakingRepository( rawCurrencyId, cryptoCurrency.symbol, ) - null -> flowOf(StakingAvailability.Unavailable) } availabilityFlow.collect { send(it) } @@ -76,20 +71,15 @@ internal class DefaultStakingRepository( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): StakingAvailability { - if (!checkFeatureToggleEnabled(cryptoCurrency)) { - return StakingAvailability.Unavailable - } + val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) + ?.takeIf(stakingFeatureToggles::isIntegrationEnabled) + ?: return StakingAvailability.Unavailable if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) { return StakingAvailability.Unavailable } val rawCurrencyId = cryptoCurrency.id.rawCurrencyId - if (rawCurrencyId == null) { - return StakingAvailability.Unavailable - } - - val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) ?: return StakingAvailability.Unavailable return when (stakingIntegration) { @@ -104,7 +94,7 @@ internal class DefaultStakingRepository( override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean { return withContext(dispatchers.default) { - val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false + val balances = stakeKitBalancesStore.getAllSyncOrNull(userWalletId) ?: return@withContext false val hasDataStakingBalance by lazy { balances.any { stakingBalance -> @@ -116,18 +106,6 @@ internal class DefaultStakingRepository( } } - private fun checkFeatureToggleEnabled(cryptoCurrency: CryptoCurrency): Boolean { - return when (cryptoCurrency.network.id.toBlockchain()) { - Blockchain.Ethereum -> { - when (cryptoCurrency) { - is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled - is CryptoCurrency.Token -> true - } - } - else -> true - } - } - private fun checkForInvalidCardBatch(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { val userWallet = getUserWalletUseCase(userWalletId).getOrElse { error("Failed to get user wallet") diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt index 4ddab5988c..8937c4ec40 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt @@ -8,11 +8,11 @@ import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.staking.multi.MultiStakingBalanceProducer import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier import com.tangem.domain.staking.single.SingleStakingBalanceProducer import com.tangem.domain.staking.single.SingleStakingBalanceSupplier +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -54,7 +54,7 @@ internal object StakingBalanceSupplierModule { fun provideSingleStakingBalanceSupplier( factory: SingleStakingBalanceProducer.Factory, ): SingleStakingBalanceSupplier { - return object : SingleStakingBalanceSupplier( + return SingleStakingBalanceSupplier( factory = factory, keyCreator = { params -> listOf( @@ -65,15 +65,15 @@ internal object StakingBalanceSupplierModule { ) .joinToString(separator = "_") }, - ) {} + ) } @Provides @Singleton fun provideMultiStakingBalanceSupplier(factory: MultiStakingBalanceProducer.Factory): MultiStakingBalanceSupplier { - return object : MultiStakingBalanceSupplier( + return MultiStakingBalanceSupplier( factory = factory, keyCreator = { "multi_staking_balances_${it.userWalletId.stringValue}" }, - ) {} + ) } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index 0aa8abf1b5..a997cbce26 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -65,7 +65,7 @@ internal object StakingDataModule { return DefaultStakingRepository( stakeKitRepository = stakeKitRepository, p2pEthPoolRepository = p2pEthPoolRepository, - stakingBalanceStoreV2 = stakeKitBalancesStore, + stakeKitBalancesStore = stakeKitBalancesStore, dispatchers = dispatchers, getUserWalletUseCase = getUserWalletUseCase, stakingFeatureToggles = stakingFeatureToggles, diff --git a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt index 011cc73bf9..6f62c8bc06 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt @@ -2,12 +2,35 @@ package com.tangem.data.staking.toggles import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.toggles.StakingFeatureToggles internal class DefaultStakingFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, ) : StakingFeatureToggles { - override val isEthStakingEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) + override fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean { + val toggle = integrationId.getFeatureToggle() ?: return true + return featureTogglesManager.isFeatureEnabled(toggle) + } + + private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) { + is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED + is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle() + } + + private fun StakingIntegrationID.StakeKit.getStakeKitFeatureToggle(): FeatureToggles? = when (this) { + is StakingIntegrationID.StakeKit.Coin -> when (this) { + StakingIntegrationID.StakeKit.Coin.Ton, + StakingIntegrationID.StakeKit.Coin.Solana, + StakingIntegrationID.StakeKit.Coin.Cosmos, + StakingIntegrationID.StakeKit.Coin.Tron, + StakingIntegrationID.StakeKit.Coin.BSC, + StakingIntegrationID.StakeKit.Coin.Cardano, + -> null + } + is StakingIntegrationID.StakeKit.EthereumToken -> when (this) { + StakingIntegrationID.StakeKit.EthereumToken.Polygon -> null + } + } } \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt new file mode 100644 index 0000000000..ca6766c8c8 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt @@ -0,0 +1,61 @@ +package com.tangem.data.staking.toggles + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.staking.model.StakingIntegrationID +import com.google.common.truth.Truth.assertThat +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultStakingFeatureTogglesTest { + + private val featureTogglesManager: FeatureTogglesManager = mockk() + private val toggles = DefaultStakingFeatureToggles(featureTogglesManager = featureTogglesManager) + + @BeforeEach + fun resetMocks() { + clearMocks(featureTogglesManager) + } + + @Test + fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() { + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true + + assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue() + + verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } + } + + @Test + fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() { + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false + + assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse() + + verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } + } + + @Test + fun `existing StakeKit Coin integrations are always enabled`() { + StakingIntegrationID.StakeKit.Coin.entries.forEach { coin -> + assertThat(toggles.isIntegrationEnabled(coin)).isTrue() + } + + verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } + } + + @Test + fun `existing StakeKit EthereumToken integrations are always enabled`() { + StakingIntegrationID.StakeKit.EthereumToken.entries.forEach { token -> + assertThat(toggles.isIntegrationEnabled(token)).isTrue() + } + + verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt index 48607769ea..ca7b011917 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt @@ -7,8 +7,8 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.core.error.UniversalError import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.data.pay.util.TangemPayErrorConverter -import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory @@ -16,14 +16,8 @@ import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val TAG = "TangemPay: DefaultTangemPayCryptoCurrencyFactory" -/** - * Custom token parameters. Will be used only for F&F. - */ -private const val TOKEN_ID = "usd-coin" -private const val TOKEN_NAME = "USDC" -private const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" -private const val TOKEN_DECIMALS = 6 +@Deprecated("Use TangemPayCurrencyFactory instead") internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor( excludedBlockchains: ExcludedBlockchains, private val errorConverter: TangemPayErrorConverter, @@ -47,32 +41,11 @@ internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor( ) cryptoCurrencyFactory.createToken( network = requireNotNull(network), - rawId = CryptoCurrency.RawID(TOKEN_ID), - name = TOKEN_NAME, - symbol = TOKEN_NAME, - contractAddress = TOKEN_CONTRACT_ADDRESS, - decimals = TOKEN_DECIMALS, - ) - }.mapLeft { exception -> - TangemLogger.withTag(TAG).e("Error", exception) - errorConverter.convert(exception) - } - } - - override fun create(userWallet: UserWallet): Either { - return catch { - val network = networkFactory.create( - blockchain = VisaUtilities.visaBlockchain, - extraDerivationPath = null, - userWallet = userWallet, - ) - cryptoCurrencyFactory.createToken( - network = requireNotNull(network), - rawId = CryptoCurrency.RawID(TOKEN_ID), - name = TOKEN_NAME, - symbol = TOKEN_NAME, - contractAddress = TOKEN_CONTRACT_ADDRESS, - decimals = TOKEN_DECIMALS, + rawId = CryptoCurrency.RawID(TangemPayCurrencyFactory.TOKEN_ID), + name = TangemPayCurrencyFactory.TOKEN_NAME, + symbol = TangemPayCurrencyFactory.TOKEN_NAME, + contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS, + decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, ) }.mapLeft { exception -> TangemLogger.withTag(TAG).e("Error", exception) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index a42852436a..57eb9b4402 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -1,11 +1,12 @@ package com.tangem.data.pay.converter -import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convert -import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convertBack +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue -import com.tangem.utils.converter.TwoWayConverter +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject +import javax.inject.Singleton /** * Two-way converter between [PaymentAccountStatusValue] and [PaymentAccountStatusValueDM]. @@ -15,10 +16,12 @@ import com.tangem.utils.converter.TwoWayConverter * * [convertBack] maps data model → domain. All restored statuses have [StatusSource.CACHE] as source. */ -internal object PaymentAccountStatusValueDMConverter : - TwoWayConverter { +@Singleton +internal class PaymentAccountStatusValueDMConverter @Inject constructor( + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, +) { - override fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? { + fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? { return when (value) { is PaymentAccountStatusValue.NotCreated -> PaymentAccountStatusValueDM.NotCreated() is PaymentAccountStatusValue.UnderReview -> PaymentAccountStatusValueDM.UnderReview( @@ -60,7 +63,7 @@ internal object PaymentAccountStatusValueDMConverter : } } - override fun convertBack(value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { + fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { return when (value) { is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated is PaymentAccountStatusValueDM.CardIssueFailed -> PaymentAccountStatusValue.Error.CardIssueFailed( @@ -80,6 +83,7 @@ internal object PaymentAccountStatusValueDMConverter : isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), ) } else { PaymentAccountStatusValue.Loaded( @@ -92,6 +96,7 @@ internal object PaymentAccountStatusValueDMConverter : isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), ) } is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 3887f44e6f..d697221c1b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -6,6 +6,7 @@ import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory import com.tangem.data.pay.DefaultTangemPayEligibilityManager +import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer import com.tangem.data.pay.repository.* @@ -24,6 +25,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.* +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase @@ -112,6 +114,7 @@ internal interface TangemPayDataModule { @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, scope: AppCoroutineScope, + converter: PaymentAccountStatusValueDMConverter, ): PaymentAccountStatusesStore { return PaymentAccountStatusesStore( runtimeStore = RuntimeSharedStore(), @@ -124,6 +127,7 @@ internal interface TangemPayDataModule { produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") }, scope = scope, ), + converter = converter, scope = scope, ) } @@ -139,6 +143,14 @@ internal interface TangemPayDataModule { ) {} } + @Provides + @Singleton + fun provideGetTangemPayCryptoCurrencyStatusUseCase( + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + ): GetPaymentAccountCryptoCurrencyStatusUseCase { + return GetPaymentAccountCryptoCurrencyStatusUseCase(paymentAccountStatusSupplier) + } + @Provides @Singleton fun provideTangemPayMainScreenCustomerInfoUseCase( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt new file mode 100644 index 0000000000..ede6bba797 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt @@ -0,0 +1,49 @@ +package com.tangem.data.pay.entity + +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.common.network.NetworkFactory +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.requireUserWalletsSync +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class TangemPayCurrencyFactory @Inject constructor( + excludedBlockchains: ExcludedBlockchains, + private val userWalletsListRepository: UserWalletsListRepository, + private val networkFactory: NetworkFactory, +) { + private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + CryptoCurrencyFactory(excludedBlockchains) + } + + fun create(userWalletId: UserWalletId): CryptoCurrency.Token { + val userWallet = userWalletsListRepository.requireUserWalletsSync() + .firstOrNull { it.walletId == userWalletId } + ?: error("User wallet with id $userWalletId not found") + val network = networkFactory.create( + blockchain = VisaUtilities.visaBlockchain, + userWallet = userWallet, + extraDerivationPath = null, + ) + return cryptoCurrencyFactory.createToken( + network = requireNotNull(network), + rawId = CryptoCurrency.RawID(TOKEN_ID), + name = TOKEN_NAME, + symbol = TOKEN_NAME, + contractAddress = TOKEN_CONTRACT_ADDRESS, + decimals = TOKEN_DECIMALS, + ) + } + + companion object { + internal const val TOKEN_ID = "usd-coin" + internal const val TOKEN_NAME = "USDC" + internal const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" + internal const val TOKEN_DECIMALS = 6 + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index cdd78706de..9e0ae38e91 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.data.pay.flow import arrow.core.Either +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.domain.core.utils.catchOn import com.tangem.domain.models.StatusSource @@ -8,6 +9,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.OrderStatus @@ -29,6 +31,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val customerOrderRepository: CustomerOrderRepository, private val deviceSecurity: DeviceSecurityInfoProvider, private val dispatchers: CoroutineDispatcherProvider, + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, ) : PaymentAccountStatusFetcher { private val logger = TangemLogger.withTag(TAG) @@ -132,7 +135,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( }, ifRight = { customerInfo -> logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}") - val status = customerInfo.mapToPaymentAccountStatus() + val status = customerInfo.mapToPaymentAccountStatus(account.userWalletId) if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) { // If order id wasn't saved -> start order creation and get customer info onboardingRepository.createOrder(account.userWalletId) @@ -167,7 +170,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( }, ifRight = { customerInfo -> if (customerInfo.kycStatus == KycStatus.REJECTED) { - customerInfo.mapToPaymentAccountStatus() + customerInfo.mapToPaymentAccountStatus(account.userWalletId) } else { PaymentAccountStatusValue.Error.CardIssueFailed( customerId = orderData.customerId, @@ -182,7 +185,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId) .fold( ifLeft = { it.mapToPaymentAccountStatus() }, - ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() }, + ifRight = { it.mapToPaymentAccountStatus(account.userWalletId) }, ) } OrderStatus.UNKNOWN -> PaymentAccountStatusValue.Error.Unavailable @@ -191,7 +194,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } - private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue { + private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { val cardInfo = this.cardInfo val productInstance = this.productInstance return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) { @@ -202,6 +205,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } else if (cardInfo != null && productInstance != null && !customerId.isNullOrEmpty()) { convertToContentState( + userWalletId = userWalletId, productInstance = productInstance, cardInfo = cardInfo, customerId = requireNotNull(customerId) { "CustomerId must not be null" }, @@ -212,10 +216,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( } private fun convertToContentState( + userWalletId: UserWalletId, productInstance: CustomerInfo.ProductInstance, cardInfo: CustomerInfo.CardInfo, customerId: String, ): PaymentAccountStatusValue { + val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) return when (productInstance.frozenState) { TangemPayCardFrozenState.Frozen -> PaymentAccountStatusValue.Locked( source = StatusSource.ACTUAL, @@ -227,6 +233,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( isPinSet = cardInfo.isPinSet, fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, + cryptoCurrency = cryptoCurrency, ) else -> PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, @@ -238,6 +245,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( isPinSet = cardInfo.isPinSet, fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, + cryptoCurrency = cryptoCurrency, ) } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index ec8a48d880..62accbccf7 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -29,6 +29,7 @@ internal typealias WalletIdWithPaymentStatusDM = Map, private val persistenceDataStore: DataStore, + private val converter: PaymentAccountStatusValueDMConverter, scope: AppCoroutineScope, ) { @@ -39,7 +40,7 @@ internal class PaymentAccountStatusesStore( runtimeStore.store( value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) -> val account = Account.Payment(userWalletId = UserWalletId(rawUserWalletId)) - val statusValue = PaymentAccountStatusValueDMConverter.convertBack(value = statusDM) + val statusValue = converter.convertBack(userWalletId = account.userWalletId, value = statusDM) AccountStatus.Payment(account = account, value = statusValue) }, ) @@ -87,7 +88,7 @@ internal class PaymentAccountStatusesStore( } private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatusValue) { - val statusDM = PaymentAccountStatusValueDMConverter.convert(value = status) ?: return + val statusDM = converter.convert(value = status) ?: return persistenceDataStore.updateData { storedStatuses -> storedStatuses.toMutableMap().apply { put(key = userWalletId.stringValue, value = statusDM) diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt index c347bb1e54..3628a45990 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.wallets.derivations import arrow.core.getOrElse import com.tangem.common.extensions.ByteArrayKey +import com.tangem.common.extensions.toMapKey import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncStrict @@ -76,6 +77,23 @@ internal class DefaultDerivationsRepository @Inject constructor( } } + override suspend fun getExistingDerivedKeys( + userWalletId: UserWalletId, + seedKey: ByteArrayKey, + ): ExtendedPublicKeysMap { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + return userWallet.getExistingDerivedKeys()[seedKey] ?: ExtendedPublicKeysMap(emptyMap()) + } + + private fun UserWallet.getExistingDerivedKeys(): Map { + return when (this) { + is UserWallet.Cold -> scanResponse.derivedKeys + is UserWallet.Hot -> wallets + ?.associate { it.publicKey.toMapKey() to ExtendedPublicKeysMap(it.derivedKeys) } + .orEmpty() + } + } + override suspend fun hasMissedDerivations( userWalletId: UserWalletId, networksWithDerivationPath: Map, diff --git a/domain/dynamic-addresses/build.gradle.kts b/domain/dynamic-addresses/build.gradle.kts index 6190ff052f..d366a99097 100644 --- a/domain/dynamic-addresses/build.gradle.kts +++ b/domain/dynamic-addresses/build.gradle.kts @@ -13,8 +13,12 @@ dependencies { api(projects.domain.dynamicAddresses.models) implementation(projects.domain.models) + implementation(projects.domain.walletManager) + implementation(projects.domain.wallets) + implementation(projects.libs.blockchainSdk) implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + implementation(tangemDeps.card.core) } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt new file mode 100644 index 0000000000..3e732ca6dc --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt @@ -0,0 +1,53 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchain.common.Blockchain + +/** + * List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode). + * Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred). + * + * Per ASMPT-005: DA is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses. + * Only the default derivation style per blockchain is supported. + */ +object DynamicAddressesSupportedBlockchains { + + private const val BIP44_PURPOSE = 44L + private const val BIP84_PURPOSE = 84L + + private val supported = setOf( + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + Blockchain.BitcoinCash, + Blockchain.BitcoinCashTestnet, + Blockchain.Litecoin, + Blockchain.Dogecoin, + Blockchain.Dash, + Blockchain.Ravencoin, + Blockchain.RavencoinTestnet, + ) + + private val supportedNetworkIds = supported.map { it.id }.toSet() + + /** + * Allowed BIP purpose nodes per network ID. + * BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH). + */ + private val allowedPurposeByNetworkId: Map = buildMap { + put(Blockchain.Bitcoin.id, BIP84_PURPOSE) + put(Blockchain.BitcoinTestnet.id, BIP84_PURPOSE) + put(Blockchain.Litecoin.id, BIP84_PURPOSE) + put(Blockchain.BitcoinCash.id, BIP44_PURPOSE) + put(Blockchain.BitcoinCashTestnet.id, BIP44_PURPOSE) + put(Blockchain.Dogecoin.id, BIP44_PURPOSE) + put(Blockchain.Dash.id, BIP44_PURPOSE) + put(Blockchain.Ravencoin.id, BIP44_PURPOSE) + put(Blockchain.RavencoinTestnet.id, BIP44_PURPOSE) + } + + fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported + + fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds + + /** Returns the allowed BIP purpose node for the given network, or null if not supported */ + fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId] +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt new file mode 100644 index 0000000000..10d9872222 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.dynamicaddresses + +sealed class EnableDynamicAddressesError { + + data object ConflictingCustomTokens : EnableDynamicAddressesError() + + data class ServiceError(val cause: Throwable) : EnableDynamicAddressesError() +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt index 8b888a54f4..7a4ceefba5 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt @@ -1,6 +1,8 @@ package com.tangem.domain.dynamicaddresses import arrow.core.Either +import arrow.core.left +import arrow.core.right import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -9,8 +11,19 @@ class EnableDynamicAddressesUseCase( private val dynamicAddressesRepository: DynamicAddressesRepository, ) { - suspend operator fun invoke(userWalletId: UserWalletId, network: Network, xpub: String): Either = - Either.catch { + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + xpub: String, + ): Either { + return try { + if (dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)) { + return EnableDynamicAddressesError.ConflictingCustomTokens.left() + } dynamicAddressesRepository.enable(userWalletId, network, xpub) + Unit.right() + } catch (e: Throwable) { + EnableDynamicAddressesError.ServiceError(e).left() } + } } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt new file mode 100644 index 0000000000..3a62af2f51 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt @@ -0,0 +1,37 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.derivations.DerivationsRepository + +/** + * Checks if the account-level XPUB key is already derived (no card scan needed). + */ +class IsXpubDerivedUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val derivationsRepository: DerivationsRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean { + val blockchain = network.toBlockchain() + if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false + if (!blockchain.isBip44DerivationStyleXPUB()) return false + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false + val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return false + if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return false + val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT)) + + val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey) + val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey) + return existingKeys[accountPath] != null + } + + private companion object { + const val ACCOUNT_PATH_DROP_COUNT = 2 + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt new file mode 100644 index 0000000000..ab998d8301 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade + +/** + * Checks if XPUB generation is supported for the given wallet and network (hardware capability check). + */ +class IsXpubSupportedUseCase( + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean { + val blockchain = network.toBlockchain() + if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false + return walletManager.wallet.publicKey.derivationType?.hdKey != null + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt index c30355fa48..4bcb627e16 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt @@ -19,4 +19,7 @@ interface DynamicAddressesRepository { suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String? suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean + + /** Returns true if there are custom tokens with change/index ≠ 0 that conflict with DA */ + suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt b/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt deleted file mode 100644 index a9d9660b18..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.domain.redux - -import org.rekotlin.Action - -sealed interface LegacyAction : Action { - - data object PrepareDetailsScreen : LegacyAction -} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index fc44ccc105..53beaf23be 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -2,9 +2,13 @@ package com.tangem.domain.models.account import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.serialization.SerializedBigDecimal import kotlinx.serialization.Serializable +import java.math.BigDecimal /** * Represents the various states a payment account can have, encapsulating different information based on the state. @@ -104,7 +108,29 @@ sealed class PaymentAccountStatusValue { val isPinSet: Boolean, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, - ) : PaymentAccountStatusValue() + val cryptoCurrency: CryptoCurrency.Token, + ) : PaymentAccountStatusValue() { + val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = cryptoCurrency, + value = CryptoCurrencyStatus.Loaded( + amount = cryptoBalance.balance, + fiatAmount = fiatBalance.availableBalance, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = cryptoBalance.depositAddress, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ), + ) + } /** * Represents a state where the payment account is successfully loaded with complete information. @@ -130,7 +156,29 @@ sealed class PaymentAccountStatusValue { val isPinSet: Boolean, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, - ) : PaymentAccountStatusValue() + val cryptoCurrency: CryptoCurrency.Token, + ) : PaymentAccountStatusValue() { + val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = cryptoCurrency, + value = CryptoCurrencyStatus.Loaded( + amount = cryptoBalance.balance, + fiatAmount = fiatBalance.availableBalance, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = cryptoBalance.depositAddress, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ), + ) + } /** Represents an error state for the payment account status. */ @Serializable diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt b/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt index 98a25e4996..40fc76fed4 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt @@ -3,5 +3,5 @@ package com.tangem.domain.search.model data class SearchResult( val textHints: List, val recentTokens: List, - val userAssets: List, + val userAssets: List, ) \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt index fd64a23e24..c6a3d45854 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt @@ -2,6 +2,7 @@ package com.tangem.domain.search.model import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId @@ -10,5 +11,6 @@ data class UserAssetSearchEntry( val userWalletName: String, val accountId: AccountId, val accountName: AccountName, + val accountIcon: CryptoPortfolioIcon, val currencyStatus: CryptoCurrencyStatus, ) \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt new file mode 100644 index 0000000000..7a63289486 --- /dev/null +++ b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.search.model + +sealed interface UserAssetSearchItem { + + data class Single(val entry: UserAssetSearchEntry) : UserAssetSearchItem + + data class Grouped( + val tokenName: String, + val tokenSymbol: String, + val tokenIconUrl: String?, + val entries: List, + ) : UserAssetSearchItem +} \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt index b6167208a2..0b6b37be48 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt @@ -9,10 +9,12 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.search.model.SearchResult import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.repository.SearchRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map +import java.math.BigDecimal /** * Primary search use case that produces [SearchResult] based on the current query. @@ -70,9 +72,12 @@ class GetSearchResultsUseCase( if (unlockedWallets.isEmpty()) return@combine emptyList() - statusLists + val entries = statusLists .filter { it.userWalletId in unlockedWallets } .flatMap { statusList -> extractMatchingAssets(statusList, unlockedWallets, lowerQuery) } + + val shouldGroup = needsGrouping(unlockedWallets.values, statusLists) + groupAndSort(entries, shouldGroup) }.map { userAssets -> SearchResult( textHints = emptyList(), @@ -82,6 +87,44 @@ class GetSearchResultsUseCase( } } + private fun needsGrouping(unlockedWallets: Collection, statusLists: List): Boolean { + if (unlockedWallets.size > 1) return true + + val totalAccounts = statusLists + .filter { sl -> unlockedWallets.any { it.walletId == sl.userWalletId } } + .sumOf { it.accountStatuses.filterCryptoPortfolio().size } + + return totalAccounts > 1 + } + + private fun groupAndSort(entries: List, shouldGroup: Boolean): List { + if (!shouldGroup) { + return entries + .map { UserAssetSearchItem.Single(it) } + .sortedByDescending { it.entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + } + + val grouped = entries.groupBy { entry -> + val rawId = entry.currencyStatus.currency.id.rawCurrencyId + rawId?.value ?: "${entry.currencyStatus.currency.name}|${entry.currencyStatus.currency.symbol}" + } + + return grouped.map { (_, groupEntries) -> + val assetInfo = groupEntries.first() + UserAssetSearchItem.Grouped( + tokenName = assetInfo.currencyStatus.currency.name, + tokenSymbol = assetInfo.currencyStatus.currency.symbol, + tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl, + entries = groupEntries, + ) + }.sortedByDescending { item -> + when (item) { + is UserAssetSearchItem.Grouped -> + item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + } + } + } + private fun extractMatchingAssets( statusList: AccountStatusList, wallets: Map, @@ -103,6 +146,7 @@ class GetSearchResultsUseCase( userWalletName = wallet.name, accountId = accountStatus.accountId, accountName = accountStatus.account.accountName, + accountIcon = accountStatus.account.icon, currencyStatus = currencyStatus, ) } diff --git a/domain/staking/detekt-baseline-debug.xml b/domain/staking/detekt-baseline-debug.xml deleted file mode 100644 index f52428665c..0000000000 --- a/domain/staking/detekt-baseline-debug.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - MultilineLambdaItParameter:FetchStakingYieldBalanceUseCase.kt$FetchStakingYieldBalanceUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } - MultilineLambdaItParameter:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase${ !it.isPending && action.amount < it.amount && it.type == BalanceType.STAKED && it.validatorAddress == action.validatorAddress } - NamedArguments:GetConstructedStakingTransactionUseCase.kt$GetConstructedStakingTransactionUseCase$constructTransaction(networkId, fee, amount, transactionId) - UnnecessaryAbstractClass:MultiStakingBalanceSupplier.kt$MultiStakingBalanceSupplier$MultiStakingBalanceSupplier - UnnecessaryAbstractClass:SingleStakingBalanceSupplier.kt$SingleStakingBalanceSupplier$SingleStakingBalanceSupplier - UseEmptyCounterpart:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$mapOf() - UseOrEmpty:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase$action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "" - - diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt index a438d88cef..2e4836733e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt @@ -23,9 +23,9 @@ class FetchStakingYieldBalanceUseCase( currencyId = cryptoCurrency.id, network = cryptoCurrency.network, ) - .getOrElse { - when (it) { - is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) + .getOrElse { error -> + when (error) { + is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$error")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt index 3402b6bb03..7242d70f51 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt @@ -20,7 +20,12 @@ class GetConstructedStakingTransactionUseCase( amount: Amount, transactionId: String, ): Either> = Either.catch { - stakeKitRepository.constructTransaction(networkId, fee, amount, transactionId) + stakeKitRepository.constructTransaction( + networkId = networkId, + fee = fee, + amount = amount, + transactionId = transactionId, + ) }.mapLeft { stakingErrorResolver.resolve(it) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt index f24cac1f74..a58ad4c346 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt @@ -100,7 +100,7 @@ class InvalidatePendingTransactionsUseCase( type = BalanceType.STAKED, amount = action.amount, rawCurrencyId = null, - validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "", + validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0).orEmpty(), date = null, pendingActions = emptyList(), pendingActionsConstraints = emptyList(), @@ -149,10 +149,10 @@ class InvalidatePendingTransactionsUseCase( } private fun findPartialUnstake(balances: MutableList, action: StakingAction): Pair { - val index = balances.indexOfFirst { - !it.isPending && action.amount < it.amount && - it.type == BalanceType.STAKED && - it.validatorAddress == action.validatorAddress + val index = balances.indexOfFirst { balance -> + !balance.isPending && action.amount < balance.amount && + balance.type == BalanceType.STAKED && + balance.validatorAddress == action.validatorAddress } return index to action.amount } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt b/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt index c8648ad64c..6fb6297f3f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt @@ -2,23 +2,27 @@ package com.tangem.domain.staking import arrow.core.Either import arrow.core.raise.either +import arrow.core.raise.ensure import arrow.core.raise.ensureNotNull import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.staking.StakingID import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.walletmanager.WalletManagersFacade /** * Factory class for creating instances of [StakingID] * - * @property walletManagersFacade wallet manager facade + * @property walletManagersFacade wallet manager facade + * @property stakingFeatureToggles staking feature toggles * [REDACTED_AUTHOR] */ class StakingIdFactory( private val walletManagersFacade: WalletManagersFacade, + private val stakingFeatureToggles: StakingFeatureToggles, ) { /** @@ -72,6 +76,8 @@ class StakingIdFactory( ensureNotNull(integrationId) { Error.UnsupportedCurrency } + ensure(stakingFeatureToggles.isIntegrationEnabled(integrationId)) { Error.UnsupportedCurrency } + val address = defaultAddressProvider().takeUnless { it.isNullOrEmpty() } ensureNotNull(address) { Error.UnableToGetAddress(integrationId = integrationId) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index cdf1a16bad..3b67c72c6f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.staking.action.StakingActionType sealed class StakingAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent( category = "Staking", event = event, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt index 106e390f01..7932714729 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance * [REDACTED_AUTHOR] */ -abstract class MultiStakingBalanceSupplier( +open class MultiStakingBalanceSupplier( override val factory: FlowProducer.Factory, override val keyCreator: (MultiStakingBalanceProducer.Params) -> String, ) : FlowCachingSupplier>() \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt index 9474e0e172..a9050acd21 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance * [REDACTED_AUTHOR] */ -abstract class SingleStakingBalanceSupplier( +open class SingleStakingBalanceSupplier( override val factory: FlowProducer.Factory, override val keyCreator: (SingleStakingBalanceProducer.Params) -> String, ) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt index 3553692065..80761562fc 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt @@ -1,5 +1,8 @@ package com.tangem.domain.staking.toggles +import com.tangem.domain.staking.model.StakingIntegrationID + interface StakingFeatureToggles { - val isEthStakingEnabled: Boolean + + fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean } \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt index 71a5d271ed..3211cdff64 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt @@ -10,11 +10,13 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.test.core.ProvideTestModels import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -30,11 +32,16 @@ import org.junit.jupiter.params.ParameterizedTest internal class StakingIdFactoryTest { private val walletManagersFacade: WalletManagersFacade = mockk() - private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade) + private val stakingFeatureToggles: StakingFeatureToggles = mockk() + private val factory = StakingIdFactory( + walletManagersFacade = walletManagersFacade, + stakingFeatureToggles = stakingFeatureToggles, + ) @BeforeEach fun resetMocks() { - clearMocks(walletManagersFacade) + clearMocks(walletManagersFacade, stakingFeatureToggles) + every { stakingFeatureToggles.isIntegrationEnabled(any()) } returns true } @Nested @@ -66,6 +73,33 @@ internal class StakingIdFactoryTest { } } + @Test + fun `create returns UnsupportedCurrency if integration is disabled by toggle`() = runTest { + // Arrange + val userWalletId = UserWalletId(stringValue = "011") + val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON) + + every { + stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.StakeKit.Coin.Ton) + } returns false + + // Act + val actual = factory.create( + userWalletId = userWalletId, + currencyId = currency.id, + network = currency.network, + ) + + // Assert + val expected = StakingIdFactory.Error.UnsupportedCurrency + + Truth.assertThat(actual.leftOrNull()).isEqualTo(expected) + + coVerify(inverse = true) { + walletManagersFacade.getDefaultAddress(userWalletId = any(), network = any()) + } + } + @Test fun `create returns UnableToGetAddress if address is null`() = runTest { // Arrange diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt index 1004e00447..31a2537914 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt @@ -5,8 +5,8 @@ import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +@Deprecated("TangemPayCurrencyFactory") interface TangemPayCryptoCurrencyFactory { fun create(userWallet: UserWallet, chainId: Int): Either - fun create(userWallet: UserWallet): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt new file mode 100644 index 0000000000..fe66dc36f7 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt @@ -0,0 +1,34 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Option +import arrow.core.none +import arrow.core.some +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import kotlinx.coroutines.flow.firstOrNull + +class GetPaymentAccountCryptoCurrencyStatusUseCase( + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Option> { + val accountStatus = paymentAccountStatusSupplier.invoke(userWalletId).firstOrNull() ?: return none() + val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + is PaymentAccountStatusValue.Locked -> statusValue.cryptoCurrencyStatus + else -> return none() + } + return if (cryptoCurrencyStatus.currency == cryptoCurrency) { + (accountStatus.account to cryptoCurrencyStatus).some() + } else { + none() + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt index 9eefdc6899..a3ee510fde 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt @@ -29,6 +29,9 @@ interface DerivationsRepository { derivations: Map>, ): Map + /** Returns already derived extended public keys for the given [seedKey] */ + suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap + /** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWalletId: UserWalletId, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt index e548eef8b7..100979e021 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt @@ -1,7 +1,6 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either -import arrow.core.right import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.extensions.ByteArrayKey @@ -38,23 +37,37 @@ class GetExtendedPublicKeyForCurrencyUseCase( error("No derivation found") } + val seedKey = walletManager.wallet.publicKey.seedKey + val existingKeys = derivationsRepository.getExistingDerivedKeys( + userWalletId = userWalletId, + seedKey = ByteArrayKey(seedKey), + ) + var childKey = makeChildKey( isBip44DerivationStyleXPUB = blockchain.isBip44DerivationStyleXPUB(), extendedPublicKey = hdKey.extendedPublicKey, derivationPath = hdKey.path, ) + // Fill from already derived keys if available + if (childKey.extendedPublicKey == null) { + existingKeys[childKey.derivationPath]?.let { + childKey = childKey.copy(extendedPublicKey = it) + } + } + + val parentPath = childKey.derivationPath.dropLastNodes(1) var parentKey = Key( - derivationPath = childKey.derivationPath.dropLastNodes(1), - extendedPublicKey = null, + derivationPath = parentPath, + extendedPublicKey = existingKeys[parentPath], ) val pendingDerivations = getPendingDerivations(childKey, parentKey) - val derivedKeys = deriveKeys( - userWalletId = userWalletId, - seedKey = walletManager.wallet.publicKey.seedKey, - paths = pendingDerivations, - ) + val derivedKeys = if (pendingDerivations.isNotEmpty()) { + deriveKeys(userWalletId = userWalletId, seedKey = seedKey, paths = pendingDerivations) + } else { + ExtendedPublicKeysMap(emptyMap()) + } if (childKey.extendedPublicKey == null) { childKey = childKey.copy( @@ -72,22 +85,6 @@ class GetExtendedPublicKeyForCurrencyUseCase( } } - /** - * @return true if xpub generation is supported, false otherwise - */ - suspend fun isSupported(userWalletId: UserWalletId, network: Network): Either = Either.catch { - val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) - ?: error("Wallet not found for user wallet $userWalletId and network ${network.id}") - - val blockchain = network.toBlockchain() - val isSecp256k1Blockchain = Blockchain.secp256k1Blockchains(network.isTestnet).contains(blockchain) - val isHdKey = walletManager.wallet.publicKey.derivationType?.hdKey - - val isSupported = isSecp256k1Blockchain && isHdKey != null - - return isSupported.right() - } - private suspend fun deriveKeys( userWalletId: UserWalletId, seedKey: ByteArray, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt index 1a436c9f93..355ce124c1 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt @@ -3,9 +3,9 @@ package com.tangem.domain.wallets.usecase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import java.util.LinkedHashMap /** * Use case for getting list of user wallets @@ -22,9 +22,13 @@ class GetWalletsUseCase( operator fun invoke(): Flow> = userWalletsListRepository.userWallets.map { requireNotNull(it) } @Throws(IllegalArgumentException::class) - fun invokeAsMap(): Flow> = userWalletsListRepository.userWallets - .map { requireNotNull(it) } - .map { wallets -> + fun invokeAsMap(isOnlyMultiCurrency: Boolean = true): Flow> = invoke() + .map { list -> + val wallets = if (isOnlyMultiCurrency) { + list.filter { wallet -> wallet.isMultiCurrency } + } else { + list + } wallets.associateByTo( destination = linkedMapOf(), keySelector = { wallet -> wallet.walletId }, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index a4d4c58b0a..e501fd3751 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -22,8 +22,6 @@ import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint -import com.tangem.domain.redux.LegacyAction -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase @@ -61,7 +59,6 @@ internal class DetailsModel @Inject constructor( private val router: Router, private val urlOpener: UrlOpener, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - private val appStateHolder: ReduxStateHolder, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, @@ -79,9 +76,6 @@ internal class DetailsModel @Inject constructor( val state: MutableStateFlow init { - // Use to save compatibility with screens that using Redux states - bootstrapScreenState() - val isWalletConnectAvailable = runBlocking { // danger region, this works immediately, but will be refactored later with WC checkIsWalletConnectAvailableUseCase(params.userWalletId).getOrElse { throwable -> @@ -122,10 +116,6 @@ internal class DetailsModel @Inject constructor( .launchIn(modelScope) } - private fun bootstrapScreenState() { - appStateHolder.dispatch(LegacyAction.PrepareDetailsScreen) - } - private fun sendFeedback() { modelScope.launch { val userWallets = getWalletsUseCase.invokeSync() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index a51d8689f7..fb93121de6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -148,6 +148,13 @@ internal class FeedEntryChildFactory @Inject constructor( appComponentContext = appComponentContext, params = DefaultSearchComponent.Params( onBackClick = onBackClicked, + onMarketTokenClick = { token, currency -> + feedEntryClickIntents.onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.Market.value, + ) + }, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt index b8a8ac1119..677f40ed5a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt @@ -15,6 +15,8 @@ import com.tangem.core.ui.ds.field.search.TangemFieldShape import com.tangem.core.ui.ds.field.search.TangemSearchField import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.model.search.SearchModel import com.tangem.features.feed.ui.search.SearchContent import com.tangem.features.feed.ui.search.state.SearchCallbacks @@ -74,6 +76,7 @@ internal class DefaultSearchComponent( onClearHintsClick = model::clearSearchHistory, onTextHintClick = model::onTextHintClick, onResultMarketTokenClick = model::onResultMarketTokenClick, + onHistoryTokenClick = model::onHistoryTokenClick, ) } SearchContent( @@ -86,5 +89,6 @@ internal class DefaultSearchComponent( data class Params( val onBackClick: () -> Unit, + val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt index 2273f437bb..f43e8c5766 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt @@ -1,19 +1,21 @@ package com.tangem.features.feed.model.search import arrow.core.getOrElse +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.common.ui.charts.state.MarketChartData -import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter -import com.tangem.common.ui.charts.state.sorted +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.domain.markets.GetTokenPriceChartUseCase import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.domain.models.account.AccountName +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase import com.tangem.domain.search.usecase.GetSearchResultsUseCase import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase @@ -26,6 +28,7 @@ import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecent import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency +import com.tangem.features.feed.model.search.converter.UserAssetSearchItemConverter import com.tangem.features.feed.model.search.state.SearchStateController import com.tangem.features.feed.model.search.state.transformers.* import com.tangem.features.feed.ui.search.state.* @@ -35,13 +38,8 @@ import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L @@ -54,6 +52,7 @@ internal class SearchModel @Inject constructor( paramsContainer: ParamsContainer, getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSearchResultsUseCase: GetSearchResultsUseCase, private val saveSearchQueryUseCase: SaveSearchQueryUseCase, private val saveRecentSearchTokenUseCase: SaveRecentSearchTokenUseCase, @@ -77,6 +76,13 @@ internal class SearchModel @Inject constructor( initialValue = AppCurrency.Default, ) + private val isBalanceHidden = getBalanceHidingSettingsUseCase.isBalanceHidden() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + private val marketsListItemToRecentSearchTokenConverter by lazy { MarketsListItemUMToRecentSearchTokenConverter() } @@ -143,17 +149,38 @@ internal class SearchModel @Inject constructor( ) saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input)) saveSearchQueryUseCase(stateController.value.searchBar.query) + withContext(dispatchers.mainImmediate) { + searchMarketsListManager.getTokenById(item.id)?.let { found -> + params.onMarketTokenClick(found.toSerializableParam(), appCurrency) + } + } } } + fun onHistoryTokenClick(item: MarketsListItemUM) { + val tokenMarketParams = TokenMarketParams( + id = item.id, + name = item.name, + symbol = item.currencySymbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = item.price.fiatPrice, + h24Percent = null, + weekPercent = null, + monthPercent = null, + ), + imageUrl = item.iconUrl, + ) + params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value) + } + private fun initCallbacks() { stateController.update(object : SearchUMTransformer { override fun transform(prevState: SearchUM): SearchUM { return prevState.copy( searchBar = prevState.searchBar.copy( onQueryChange = ::onQueryChange, - onActiveChange = ::onActiveChange, onClearClick = ::onClearClick, + onCancelClick = params.onBackClick, ), ) } @@ -164,10 +191,6 @@ internal class SearchModel @Inject constructor( stateController.update(UpdateSearchBarQueryTransformer(query)) } - private fun onActiveChange(isActive: Boolean) { - if (!isActive) params.onBackClick() - } - private fun onClearClick() { stateController.update(UpdateSearchBarQueryTransformer("")) } @@ -200,20 +223,19 @@ internal class SearchModel @Inject constructor( private fun subscribeToSearchResults(query: String) { modelScope.launch { - getSearchResultsUseCase(query = query).collectLatest { searchResult -> - val userAssets = searchResult.userAssets.map { entry -> - UserAssetItemUM( - id = "${entry.userWalletId.stringValue}_${entry.accountId.value}" + - "_${entry.currencyStatus.currency.id.value}", - tokenIconUrl = entry.currencyStatus.currency.iconUrl, - tokenName = entry.currencyStatus.currency.name, - tokenSymbol = entry.currencyStatus.currency.symbol, - accountName = entry.accountName.toDisplayString(), - onClick = { - // TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task. - }, - ) - }.toImmutableList() + combine( + getSearchResultsUseCase(query = query), + currentAppCurrency, + isBalanceHidden, + ) { searchResult, appCurrency, balanceHidden -> + val converter = UserAssetSearchItemConverter( + appCurrency = appCurrency, + isBalanceHidden = balanceHidden, + ) + searchResult.userAssets + .map(converter::convert) + .toImmutableList() + }.collectLatest { userAssets -> stateController.update(UpdateUserAssetsTransformer(userAssets)) } }.saveIn(searchResultsJob) @@ -334,13 +356,6 @@ internal class SearchModel @Inject constructor( } } - private fun AccountName.toDisplayString(): String { - return when (this) { - is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize - is AccountName.Custom -> value - } - } - private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { launch { while (true) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt new file mode 100644 index 0000000000..4e742afdde --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt @@ -0,0 +1,92 @@ +package com.tangem.features.feed.model.search.converter + +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.search.model.UserAssetSearchItem +import com.tangem.features.feed.ui.search.state.UserAssetItemUM +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +internal class UserAssetSearchItemConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, +) : Converter { + + override fun convert(value: UserAssetSearchItem): UserAssetItemUM { + return when (value) { + is UserAssetSearchItem.Single -> convertSingle(value.entry) + is UserAssetSearchItem.Grouped -> convertGrouped(value) + } + } + + private fun convertSingle(entry: UserAssetSearchEntry): UserAssetItemUM.Single { + val currency = entry.currencyStatus.currency + val value = entry.currencyStatus.value + + return UserAssetItemUM.Single( + id = "${entry.userWalletId.stringValue}_${entry.accountId.value}_${currency.id.value}", + icon = TangemIconUM.Currency( + currencyIconState = CryptoCurrencyToIconStateConverter().convert(entry.currencyStatus), + ), + tokenName = currency.name, + tokenSymbol = currency.symbol, + fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) }, + cryptoBalance = formatCryptoAmount(value.amount, currency.symbol, currency.decimals), + fiatBalance = formatFiatAmount(value.fiatAmount), + isBalanceHidden = isBalanceHidden, + onClick = {}, + ) + } + + private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped { + val totalFiat = item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + val totalCrypto = item.entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO } + + val firstCurrency = item.entries.first().currencyStatus.currency + val children = item.entries.map { entry -> + UserAssetItemUM.GroupedChild( + walletName = entry.userWalletName, + accountName = entry.accountName.toUM(), + accountIcon = entry.accountIcon.value, + accountColor = entry.accountIcon.color, + cryptoBalance = formatCryptoAmount( + entry.currencyStatus.value.amount, + entry.currencyStatus.currency.symbol, + entry.currencyStatus.currency.decimals, + ), + fiatBalance = formatFiatAmount(entry.currencyStatus.value.fiatAmount), + ) + }.toImmutableList() + + return UserAssetItemUM.Grouped( + id = "grouped_${item.tokenName}_${item.tokenSymbol}", + icon = TangemIconUM.Currency( + currencyIconState = CryptoCurrencyToIconStateConverter().convert(item.entries.first().currencyStatus), + ), + tokenName = item.tokenName, + tokenSymbol = item.tokenSymbol, + tokensCount = item.entries.size, + totalCryptoBalance = formatCryptoAmount(totalCrypto, firstCurrency.symbol, firstCurrency.decimals), + totalFiatBalance = formatFiatAmount(totalFiat), + isBalanceHidden = isBalanceHidden, + children = children, + onClick = {}, + ) + } + + private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String { + return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN + } + + private fun formatFiatAmount(fiatAmount: BigDecimal?): String { + return fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } ?: StringsSigns.DASH_SIGN + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 287b580b15..6d7118f812 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -3,20 +3,16 @@ package com.tangem.features.feed.ui.search import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.draw.drawBehind import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity @@ -32,7 +28,6 @@ import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.image.TangemIcon -import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe @@ -54,6 +49,15 @@ internal fun SearchContent( val lazyListState = rememberLazyListState() val background = LocalMainBottomSheetColor.current.value + val contentStructureKey = when (content) { + is SearchContentUM.InitialEmpty -> "empty" + is SearchContentUM.History -> "history" + is SearchContentUM.Results -> "results_${content.userAssets.isNotEmpty()}" + } + LaunchedEffect(contentStructureKey) { + lazyListState.scrollToItem(0) + } + LazyColumn( state = lazyListState, modifier = modifier @@ -72,6 +76,7 @@ internal fun SearchContent( history = content, onClearAllClick = searchCallbacks.onClearHintsClick, onHintClick = searchCallbacks.onTextHintClick, + onHistoryTokenClick = searchCallbacks.onHistoryTokenClick, ) is SearchContentUM.Results -> searchResultsItems( results = content, @@ -98,6 +103,7 @@ private fun LazyListScope.searchHistoryItems( history: SearchContentUM.History, onClearAllClick: (() -> Unit), onHintClick: (String) -> Unit, + onHistoryTokenClick: (MarketsListItemUM) -> Unit, ) { if (!history.textHints.isEmpty() || !history.recentTokens.isEmpty()) { item(key = "recents") { @@ -107,15 +113,20 @@ private fun LazyListScope.searchHistoryItems( ) } } - items( + itemsIndexed( items = history.textHints, - key = { "hint_${it.text}" }, - ) { hint -> + key = { _, item -> "hint_${item.text}" }, + ) { index, hint -> TextHintItem(hint = hint, onHintClick = { onHintClick(hint.text) }) - HorizontalDivider( - modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2), - color = TangemTheme.colors2.border.neutral.primary, - ) + if (index < history.textHints.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2), + color = TangemTheme.colors2.border.neutral.primary, + ) + } + } + item { + SpacerH(TangemTheme.dimens2.x2) } items( items = history.recentTokens, @@ -129,7 +140,7 @@ private fun LazyListScope.searchHistoryItems( shape = RoundedCornerShape(TangemTheme.dimens2.x5), ), model = token, - onClick = {}, // TODO in [REDACTED_TASK_KEY] + onClick = { onHistoryTokenClick(token) }, ) } } @@ -264,9 +275,16 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) { } } -// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task. @Composable private fun UserAssetItem(asset: UserAssetItemUM) { + when (asset) { + is UserAssetItemUM.Single -> SingleUserAssetItem(asset) + is UserAssetItemUM.Grouped -> GroupedUserAssetItem(asset) + } +} + +@Composable +private fun SingleUserAssetItem(asset: UserAssetItemUM.Single) { Row( modifier = Modifier .fillMaxWidth() @@ -276,10 +294,8 @@ private fun UserAssetItem(asset: UserAssetItemUM) { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TangemIcon( - tangemIconUM = TangemIconUM.Url(asset.tokenIconUrl, fallbackRes = R.drawable.ic_custom_token_44), - modifier = Modifier - .size(40.dp) - .clip(CircleShape), + modifier = Modifier.size(40.dp), + tangemIconUM = asset.icon, ) Column(modifier = Modifier.weight(1f)) { Text( @@ -289,12 +305,79 @@ private fun UserAssetItem(asset: UserAssetItemUM) { maxLines = 1, ) Text( - text = "${asset.tokenSymbol} · ${asset.accountName}", + text = asset.tokenSymbol, style = TangemTheme.typography2.captionRegular13, color = TangemTheme.colors2.text.neutral.tertiary, maxLines = 1, ) } + if (!asset.isBalanceHidden) { + Column(horizontalAlignment = Alignment.End) { + Text( + text = asset.fiatBalance, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = asset.cryptoBalance, + style = TangemTheme.typography2.captionRegular13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + ) + } + } + } +} + +// TODO [REDACTED_JIRA] update ui item to Portfolio block item +@Composable +private fun GroupedUserAssetItem(asset: UserAssetItemUM.Grouped) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = asset.onClick) + .padding(horizontal = 12.dp, vertical = 14.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemIcon( + modifier = Modifier.size(40.dp), + tangemIconUM = asset.icon, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = asset.tokenName, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = "${asset.tokenSymbol} · ${asset.tokensCount}", + style = TangemTheme.typography2.captionRegular13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + ) + } + if (!asset.isBalanceHidden) { + Column(horizontalAlignment = Alignment.End) { + Text( + text = asset.totalFiatBalance, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = asset.totalCryptoBalance, + style = TangemTheme.typography2.captionRegular13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + ) + } + } + } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt index bfb95e3819..e5cd68f79c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt @@ -2,11 +2,7 @@ package com.tangem.features.feed.ui.search.preview import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -15,7 +11,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -206,18 +204,22 @@ internal object SearchContentPreviewFixtures { updateTimestamp = updateTimestamp, ) - private fun userAsset( - id: String, - name: String, - symbol: String, - accountName: String, - iconUrl: String? = null, - ): UserAssetItemUM = UserAssetItemUM( + private fun userAsset(id: String, name: String, symbol: String): UserAssetItemUM = UserAssetItemUM.Single( id = id, - tokenIconUrl = iconUrl, + icon = TangemIconUM.Currency( + CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.ic_ethereumpow_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), tokenName = name, tokenSymbol = symbol, - accountName = accountName, + fiatRate = "$98,765.43", + cryptoBalance = "1.234 $symbol", + fiatBalance = "$121,876.50", + isBalanceHidden = false, onClick = {}, ) @@ -251,13 +253,8 @@ internal object SearchContentPreviewFixtures { ) private fun portfolioTwo(): ImmutableList = persistentListOf( - userAsset(id = "p1", name = "Ethereum", symbol = "ETH", accountName = "Main wallet"), - userAsset( - id = "p2", - name = "Polygon", - symbol = "POL", - accountName = "Account with a long label for preview", - ), + userAsset(id = "p1", name = "Ethereum", symbol = "ETH"), + userAsset(id = "p2", name = "Polygon", symbol = "POL"), ) private fun marketListShort(): ImmutableList = persistentListOf( @@ -390,6 +387,7 @@ private val SearchContentPreviewCallbacks = SearchCallbacks( onClearHintsClick = {}, onTextHintClick = { _ -> }, onResultMarketTokenClick = { _ -> }, + onHistoryTokenClick = { _ -> }, ) /** All [SearchContentPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */ diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt index e5641b2bb8..9535331da7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt @@ -7,4 +7,5 @@ internal data class SearchCallbacks( val onClearHintsClick: () -> Unit, val onTextHintClick: (hint: String) -> Unit, val onResultMarketTokenClick: (MarketsListItemUM) -> Unit, + val onHistoryTokenClick: (MarketsListItemUM) -> Unit, ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt index e4628ad30c..ede3decb1b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt @@ -1,8 +1,11 @@ package com.tangem.features.feed.ui.search.state import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList data class SearchUM( @@ -42,11 +45,45 @@ sealed interface MarketSearchResultUM { data class TextHintItemUM(val text: String) -data class UserAssetItemUM( - val id: String, - val tokenIconUrl: String?, - val tokenName: String, - val tokenSymbol: String, - val accountName: String, - val onClick: () -> Unit, -) \ No newline at end of file +@Immutable +sealed interface UserAssetItemUM { + val id: String + val icon: TangemIconUM + val tokenName: String + val tokenSymbol: String + val onClick: () -> Unit + + data class Single( + override val id: String, + override val icon: TangemIconUM, + override val tokenName: String, + override val tokenSymbol: String, + val fiatRate: String?, + val cryptoBalance: String, + val fiatBalance: String, + val isBalanceHidden: Boolean, + override val onClick: () -> Unit, + ) : UserAssetItemUM + + data class Grouped( + override val id: String, + override val icon: TangemIconUM, + override val tokenName: String, + override val tokenSymbol: String, + val tokensCount: Int, + val totalCryptoBalance: String, + val totalFiatBalance: String, + val isBalanceHidden: Boolean, + val children: ImmutableList, + override val onClick: () -> Unit, + ) : UserAssetItemUM + + data class GroupedChild( + val walletName: String, + val accountName: AccountNameUM, + val accountIcon: CryptoPortfolioIcon.Icon, + val accountColor: CryptoPortfolioIcon.Color, + val cryptoBalance: String, + val fiatBalance: String, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 18ff42ff9e..df6dc83590 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -19,7 +19,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.CryptoCurrencyAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked -import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase @@ -69,7 +68,6 @@ internal class SendDestinationModel @Inject constructor( private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val parseQrCodeUseCase: ParseQrCodeUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory, private val analyticsEventHandler: AnalyticsEventHandler, private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, ) : Model(), SendDestinationClickIntents { @@ -260,16 +258,16 @@ internal class SendDestinationModel @Inject constructor( private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? { val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null - val address = when (val status = this.value) { - is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress - is PaymentAccountStatusValue.Locked -> status.cryptoBalance.depositAddress + val (paymentAccountAddress, currency) = when (val status = this.value) { + is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress to status.cryptoCurrency + is PaymentAccountStatusValue.Locked -> status.cryptoBalance.depositAddress to status.cryptoCurrency else -> return null } - val currency = tangemPayCryptoCurrencyFactory.create(wallet).getOrNull() ?: return null + return if (contractAddress.equals(currency.contractAddress, true)) { DestinationWalletUM( name = wallet.name, - address = address, + address = paymentAccountAddress, cryptoCurrency = currency, userWalletId = wallet.walletId, account = account, diff --git a/features/staking/impl/detekt-baseline-debug.xml b/features/staking/impl/detekt-baseline-debug.xml deleted file mode 100644 index 8cd2dca55b..0000000000 --- a/features/staking/impl/detekt-baseline-debug.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - BooleanPropertyNaming:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer$val showNotification = sendingAmount + feeAmount > balance - BooleanPropertyNaming:AmountCurrencyChangeStateTransformer.kt$AmountCurrencyChangeStateTransformer$private val value: Boolean - BooleanPropertyNaming:StakingUiState.kt$StakingStates.InitialInfoState.Data$val showBanner: Boolean - BooleanPropertyNaming:StakingUiState.kt$StakingUiState$val showColdWalletInteractionIcon: Boolean - CastNullableToNonNullableType:SetApprovalBottomSheetInProgressTransformer.kt$SetApprovalBottomSheetInProgressTransformer$as - CastNullableToNonNullableType:SetApprovalBottomSheetTypeChangeTransformer.kt$SetApprovalBottomSheetTypeChangeTransformer$as - MultilineLambdaItParameter:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer${ it is StakingNotification.Error || it is NotificationUM.Error || it is NotificationUM.Warning.NetworkFeeUnreachable || it is StakingNotification.Warning.TransactionInProgress || it is StakingNotification.Warning.InitializeTonAccount } - MultilineLambdaItParameter:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler${ val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } - MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.body1, ) } } - MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) } } - MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.PREPARING || it.type == BalanceType.STAKED || it.type == BalanceType.LOCKED } - MultilineLambdaItParameter:StakingStateController.kt$StakingStateController${ it.copy( showColdWalletInteractionIcon = userWallet is UserWallet.Cold, ) } - NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$networkId - NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$tokenId - PropertyUsedBeforeDeclaration:StakingFeeBlock.kt$FeeBlockPreviewProvider$contentState - PropertyUsedBeforeDeclaration:StakingStateController.kt$StakingStateController$uiState - UnnecessaryEventHandlerParameter:StakingInitialInfoContent.kt$onClick: (BalanceState) -> Unit - UnnecessaryLet:StakingTosText.kt$let { onTextClick(PRIVACY_POLICY_URL) } - UnnecessaryLet:StakingTosText.kt$let { onTextClick(TERMS_OF_USE_URL) } - - diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index c861dfb887..a12c16b790 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -53,9 +53,9 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( params = MultiWalletCryptoCurrenciesProducer.Params(selectedUserWalletId), ) .orEmpty() - .firstOrNull { - val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) - val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + .firstOrNull { currency -> + val isNetwork = currency.network.backendId.equals(networkId, ignoreCase = true) + val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } @@ -63,8 +63,8 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( TangemLogger.e( """ Could not get crypto currency for - |- $NETWORK_ID_KEY: $networkId - |- $TOKEN_ID_KEY: $tokenId + |- $NETWORK_ID_KEY: ${networkId.orEmpty()} + |- $TOKEN_ID_KEY: ${tokenId.orEmpty()} """.trimIndent(), ) return@launch diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt index 66b3ccedc6..9ae89b73e3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.impl.presentation.model +import androidx.compose.runtime.Immutable import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.notifications.NotificationUM @@ -11,6 +12,7 @@ import java.math.BigDecimal // TODO split this interface to click intents and other interaction events @Suppress("TooManyFunctions") +@Immutable internal interface StakingClickIntents : AmountScreenClickIntents { fun onBackClick() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index 7a02bd36ef..32844ee371 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -26,19 +26,19 @@ internal class StakingStateController @Inject constructor( private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) { - val value: StakingUiState get() = uiState.value - private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) val uiState: StateFlow get() = mutableUiState.asStateFlow() + val value: StakingUiState get() = uiState.value + private val buttonsTransformer = SetButtonsStateTransformer(urlOpener) private val titleTransformer = SetTitleTransformer fun initializeWithUserWallet(userWallet: UserWallet) { mutableUiState.update { state -> state.copy( - showColdWalletInteractionIcon = userWallet.isColdWallet, + isColdWalletInteractionIconVisible = userWallet.isColdWallet, shouldShowHoldToConfirmButton = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWallet.isHotWallet, ) @@ -89,7 +89,7 @@ internal class StakingStateController @Inject constructor( actionType = StakingActionCommonType.Enter(skipEnterAmount = false), buttonsState = NavigationButtonsState.Empty, balanceState = null, - showColdWalletInteractionIcon = true, + isColdWalletInteractionIconVisible = true, shouldShowHoldToConfirmButton = false, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index 8624ce9a72..f9a8b1cf23 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -39,22 +39,9 @@ internal data class StakingUiState( val actionType: StakingActionCommonType, val buttonsState: NavigationButtonsState, val balanceState: BalanceState?, - val showColdWalletInteractionIcon: Boolean, + val isColdWalletInteractionIconVisible: Boolean, val shouldShowHoldToConfirmButton: Boolean, -) { - - fun copyWrapped( - initialInfoState: StakingStates.InitialInfoState = this.initialInfoState, - amountState: AmountState = this.amountState, - confirmationState: StakingStates.ConfirmationState = this.confirmationState, - validatorState: StakingStates.ValidatorState = this.validatorState, - ): StakingUiState = copy( - initialInfoState = initialInfoState, - amountState = amountState, - confirmationState = confirmationState, - validatorState = validatorState, - ) -} +) internal sealed class StakingStates { @@ -64,7 +51,7 @@ internal sealed class StakingStates { sealed class InitialInfoState : StakingStates() { data class Data( override val isPrimaryButtonEnabled: Boolean, - val showBanner: Boolean, + val isBannerVisible: Boolean, val infoItems: ImmutableList, val onInfoClick: (InfoType) -> Unit, val yieldBalance: InnerYieldBalanceState, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index c54d001d68..ba083ad159 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt @@ -18,7 +18,7 @@ import kotlinx.collections.immutable.persistentListOf internal object InitialStakingStatePreview { val defaultState = StakingStates.InitialInfoState.Data( isPrimaryButtonEnabled = true, - showBanner = true, + isBannerVisible = true, infoItems = persistentListOf( RoundedListWithDividersItemData( id = R.string.staking_details_available, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt index 725c5869e8..b67b009796 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -37,7 +37,7 @@ internal class SetButtonsStateTransformer( return prevState.copy(buttonsState = buttonsState) } - private fun getPrimaryButton(prevState: StakingUiState): NavigationButton? { + private fun getPrimaryButton(prevState: StakingUiState): NavigationButton { val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data val innerConfirmState = confirmState?.innerState @@ -52,7 +52,7 @@ internal class SetButtonsStateTransformer( val isPrimaryButtonDisabled = prevState.isPrimaryButtonDisabled() return NavigationButton( textReference = prevState.getButtonText(), - iconRes = R.drawable.ic_tangem_24.takeIf { prevState.showColdWalletInteractionIcon }, + iconRes = R.drawable.ic_tangem_24.takeIf { prevState.isColdWalletInteractionIconVisible }, isDimmed = isPrimaryButtonDisabled, isIconVisible = isIconVisible, shouldShowProgress = isInProgress, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 1dbb49e096..c6c30d4bcb 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -96,7 +96,7 @@ internal class SetInitialDataStateTransformer( isPrimaryButtonEnabled = with(status) { !amount.isNullOrZero() && sources.stakingBalanceSource.isActual() && sources.networkSource.isActual() }, - showBanner = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty, + isBannerVisible = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty, infoItems = getInfoItems(), onInfoClick = clickIntents::onInfoClick, yieldBalance = yieldBalance, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt index 9631543416..5c121f20e1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt @@ -7,11 +7,11 @@ import com.tangem.utils.transformer.Transformer internal class AmountCurrencyChangeStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, - private val value: Boolean, + private val isFiatValue: Boolean, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( - amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState), + amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, isFiatValue).transform(prevState.amountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt index 5221aba998..997a7e6743 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt @@ -27,7 +27,7 @@ internal class SetApprovalBottomSheetInProgressTransformer( ), onCancel = onDismiss, ) - } as TangemBottomSheetConfigContent, + } as? TangemBottomSheetConfigContent ?: return prevState, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt index 0e58d0c7eb..099b77c51a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt @@ -16,7 +16,7 @@ internal class SetApprovalBottomSheetTypeChangeTransformer( bottomSheetConfig = prevState.bottomSheetConfig?.copy( content = approvalBottomSheetConfig?.copy( data = approvalBottomSheetConfig.data.copy(approveType = approveType), - ) as TangemBottomSheetConfigContent, + ) as? TangemBottomSheetConfigContent ?: return prevState, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt index 7bb3f0630d..8849435172 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt @@ -177,12 +177,12 @@ internal class AddStakingNotificationsTransformer( } private fun isPrimaryButtonEnabled(notifications: ImmutableList, isActualSources: Boolean) = - notifications.none { - it is StakingNotification.Error || - it is NotificationUM.Error || - it is NotificationUM.Warning.NetworkFeeUnreachable || - it is StakingNotification.Warning.TransactionInProgress || - it is StakingNotification.Warning.InitializeTonAccount + notifications.none { notification -> + notification is StakingNotification.Error || + notification is NotificationUM.Error || + notification is NotificationUM.Warning.NetworkFeeUnreachable || + notification is StakingNotification.Warning.TransactionInProgress || + notification is StakingNotification.Warning.InitializeTonAccount } && isActualSources private fun MutableList.addStakingErrorNotifications( @@ -302,8 +302,8 @@ internal class AddStakingNotificationsTransformer( val balance = cryptoCurrencyStatus.value.amount.orZero() if (!isSubtractionAvailable) return - val showNotification = sendingAmount + feeAmount > balance - if (showNotification) { + val isExceedsBalance = sendingAmount + feeAmount > balance + if (isExceedsBalance) { onNotEnoughFeeNotificationShow() val notification = if (actionType is StakingActionCommonType.Enter) { NotificationUM.Error.TotalExceedsBalance diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt index 8628070ef4..02a41fe4a3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt @@ -150,10 +150,10 @@ internal class StakingInfoNotificationsFactory( val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val isTron = isTron(cryptoCurrencyStatus.currency.network.rawId) val hasStakedBalance = (cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit)?.balance - ?.items?.any { - it.type == BalanceType.PREPARING || - it.type == BalanceType.STAKED || - it.type == BalanceType.LOCKED + ?.items?.any { item -> + item.type == BalanceType.PREPARING || + item.type == BalanceType.STAKED || + item.type == BalanceType.LOCKED } == true if (isTron && hasStakedBalance) { add( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index eb514ea0ba..66cb40cb89 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -87,7 +87,7 @@ internal fun StakingInitialInfoContent( .background(TangemTheme.colors.background.secondary) .padding(horizontal = TangemTheme.dimens.spacing16), ) { - if (state.showBanner) { + if (state.isBannerVisible) { item(key = BANNER_BLOCK_KEY) { Column( modifier = Modifier.animateItem(), @@ -175,7 +175,7 @@ private fun LazyListScope.activeStakingBlock( ActiveStakingBlock( balance = balance, isBalanceHidden = isBalanceHidden, - onClick = clickIntents::onActiveStake, + onClick = { clickIntents.onActiveStake(balance) }, onAnalytic = clickIntents::onActiveStakeAnalytic, modifier = Modifier .animateItem() @@ -288,7 +288,7 @@ private fun StakingRewardBlock( private fun ActiveStakingBlock( balance: BalanceState, isBalanceHidden: Boolean, - onClick: (BalanceState) -> Unit, + onClick: () -> Unit, onAnalytic: () -> Unit, modifier: Modifier = Modifier, ) { @@ -304,7 +304,7 @@ private fun ActiveStakingBlock( enabled = balance.isClickable, onClick = { onAnalytic() - onClick(balance) + onClick() }, ) .padding(TangemTheme.dimens.spacing12), @@ -351,20 +351,18 @@ private fun ActiveStakingBlock( style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, ) - if (balance.formattedCryptoAmount != null) { - Text( - text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), - ) - } + Text( + text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) } } } @Composable -private fun RowScope.StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) { +private fun StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) { if (balance.hasImage() || icon != null) { StakingTargetIcon( image = if (balance.hasImage()) balance.target?.image.toImageReference() else null, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt index 60ba58a048..8ad250c6d1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt @@ -109,8 +109,8 @@ private fun BoxScope.FeeLoading(feeState: FeeState) { targetState = feeState, label = "Fee Loading State Change", modifier = Modifier.align(Alignment.CenterEnd), - ) { - if (it == FeeState.Loading) { + ) { state -> + if (state == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( @@ -128,8 +128,8 @@ private fun BoxScope.FeeError(feeState: FeeState) { targetState = feeState, label = "Fee Error State Change", modifier = Modifier.align(Alignment.CenterEnd), - ) { - if (it == FeeState.Error) { + ) { state -> + if (state == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, @@ -151,13 +151,6 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va private class FeeBlockPreviewProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - contentState, - FeeState.Loading, - FeeState.Error, - ) - private val fee = Fee.Common( amount = Amount( currencySymbol = "MATIC", @@ -174,6 +167,13 @@ private class FeeBlockPreviewProvider : PreviewParameterProvider { isFeeApproximate = false, isFeeConvertibleToFiat = true, ) + + override val values: Sequence + get() = sequenceOf( + contentState, + FeeState.Loading, + FeeState.Error, + ) } // endregion \ No newline at end of file diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index ed9144cac3..2d0f54f7b2 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -2,9 +2,7 @@ package com.tangem.features.swap import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import java.math.BigDecimal @@ -17,8 +15,6 @@ interface SwapComponent : ComposableContentComponent { val isInitialReverseOrder: Boolean = false, val screenSource: String, val tangemPayInput: TangemPayInput? = null, - val preselectedToToken: CryptoCurrencyStatus? = null, - val preselectedAccount: Account? = null, ) { data class TangemPayInput( val cryptoAmount: BigDecimal, diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index 2b50f90d14..dba1d47550 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -162,8 +162,12 @@ internal class SavedSwapTransactionListConverter( } private fun findAccountByDerivationIndex(accountList: AccountList?, derivationIndex: DerivationIndex?): Account? { - return accountList?.accounts?.asSequence()?.filterIsInstance() - ?.firstOrNull { it.derivationIndex == derivationIndex } + val accounts = accountList?.accounts ?: return null + + return accounts.asSequence() + .filterIsInstance() + .firstOrNull { it.derivationIndex == derivationIndex } + ?: accounts.firstOrNull { it is Account.Payment }.takeIf { derivationIndex == null } } private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index d0baa14afb..b76d4c6e4c 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.domain.express.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.visa) implementation(projects.domain.visa.models) implementation(projects.features.swap.domain.api) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 98165323aa..ae3c82c978 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -49,9 +49,9 @@ interface SwapInteractor { @Throws(IllegalStateException::class) suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 952916beb1..a8f4b68486 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -31,7 +31,7 @@ import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -138,13 +138,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( SingleAccountStatusListProducer.Params(userWalletId), - )?.accountStatuses.orEmpty().filterCryptoPortfolio() + )?.accountStatuses.orEmpty() val walletAccountCurrencyStatusesExceptInitial: Map> = walletAccountCurrencyStatuses.mapNotNull { accountStatus -> val filteredCurrencies = when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies().filterCurrencies(currency) - is AccountStatus.Payment -> TODO("[REDACTED_JIRA]") + is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) } if (filteredCurrencies.isNotEmpty()) { @@ -188,6 +188,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { + val currencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + is PaymentAccountStatusValue.Locked -> statusValue.cryptoCurrencyStatus + else -> return emptyList() + } + + return listOf(currencyStatus) + } + private fun List.filterCurrencies(currency: CryptoCurrency) = this.filter { status -> val isDifferentCurrency = status.currency.network.backendId != currency.network.backendId || status.currency.getContractAddress() != currency.getContractAddress() @@ -211,11 +221,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( tokenInfoForFilter(pair).network == currency.network.backendId } - val accountCurrencyList = cryptoCurrenciesList.mapNotNull { (accountEntry, currencyStatusList) -> - val cryptoPortfolio = accountEntry as? Account.CryptoPortfolio ?: return@mapNotNull null - + val accountCurrencyList = cryptoCurrenciesList.map { (accountEntry, currencyStatusList) -> AccountSwapAvailability( - account = cryptoPortfolio, + account = accountEntry, currencyList = currencyStatusList.map { currencyStatus -> val providers = findProvidersForPair( cryptoCurrencyStatuses = currencyStatus, @@ -321,9 +329,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod") override suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -347,7 +355,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( return providers.associateWith { createEmptyAmountState() } } val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) - val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) + val isBalanceWithoutFeeEnough = when (fromAccount) { + is Account.Payment -> true + else -> isBalanceEnough(fromToken, amount, null) + } val networkId = fromToken.currency.network.backendId return supervisorScope { @@ -421,9 +432,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDex( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -504,9 +515,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDexSolana( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -559,9 +570,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageCex( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, @@ -1316,9 +1327,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toTokenStatus: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1410,9 +1421,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel: Either, amount: SwapAmount, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, networkId: String, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1524,7 +1535,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun createSwapErrorWith( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, @@ -1714,9 +1725,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, @@ -1858,7 +1869,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun produceDexSwapDataError( error: ExpressDataError, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, amount: SwapAmount, ): SwapState.SwapError { val rates = getQuotes(fromToken.currency.id) @@ -1955,9 +1966,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun updateBalances( provider: SwapProvider, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toTokenStatus: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, @@ -2023,7 +2034,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun updatePermissionState( networkId: String, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, spenderAddress: String?, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 89f79b2d72..d538156362 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -101,7 +101,7 @@ data class TokenSwapInfo( val tokenAmount: SwapAmount, val amountFiat: BigDecimal, val cryptoCurrencyStatus: CryptoCurrencyStatus, - val account: Account.CryptoPortfolio?, + val account: Account?, ) data class RequestApproveStateData( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt index f0587337d6..d2da881490 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -45,13 +45,13 @@ data class CurrenciesGroup( ) data class AccountSwapAvailability( - val account: Account.CryptoPortfolio, + val account: Account, val currencyList: List, ) data class AccountSwapCurrency( val isAvailable: Boolean, - val account: Account.CryptoPortfolio, + val account: Account, val cryptoCurrencyStatus: CryptoCurrencyStatus, val providers: List, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt index e87799d55e..c421ec5874 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -23,14 +24,14 @@ internal interface ChooseTokenBridge { val onClose: Channel // todo swap legacy api, remove - val onTokenSelected: Channel> + val onTokenSelected: Channel val onNewTokenAdded: Channel> val searchQueryState: StateFlow val currenciesGroup: Flow - fun onTokenSelected(tokenId: Pair) { - onTokenSelected.trySend(tokenId) + fun onTokenSelected(result: ChooseTokenResultOld) { + onTokenSelected.trySend(result) onSearchQuery("") } @@ -57,6 +58,12 @@ internal interface ChooseTokenBridge { } } +data class ChooseTokenResultOld( + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val account: Account, + val isSearched: Boolean, +) + data class ChooseTokenResult( val currency: CryptoCurrencyStatus, val account: AccountStatus, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt index 870a5a7abe..a8b7cb6142 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt @@ -4,6 +4,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult +import com.tangem.feature.swap.choosetoken.api.ChooseTokenResultOld import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup import dagger.assisted.Assisted @@ -19,7 +20,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( override val onCurrencyChosen: Channel = Channel() - override val onTokenSelected: Channel> = Channel() + override val onTokenSelected: Channel = Channel() override val onNewTokenAdded: Channel> = Channel() override val onClose: Channel = Channel() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index a130a0eabf..ee77c4eeec 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -6,9 +6,12 @@ import com.tangem.common.ui.tokens.TokenConverterParams import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio @@ -17,6 +20,7 @@ import com.tangem.domain.models.tokenlist.TokenList import com.tangem.feature.swap.choosetoken.impl.model.ClickIntents import com.tangem.feature.swap.choosetoken.impl.model.isSearchingState import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.toPersistentList internal class ChooseTokenListItemConverter( @@ -57,10 +61,9 @@ internal class ChooseTokenListItemConverter( if (accountItems.isEmpty()) { return TokenListUMData.EmptyList } - val accountsList = accountItems.toPersistentList() return TokenListUMData.AccountList( - tokensList = accountsList, - totalTokensCount = accountsList.size, + tokensList = accountItems.toPersistentList(), + totalTokensCount = accountItems.sumOf { portfolio -> portfolio.tokensItemsList.size }, ) } @@ -77,11 +80,19 @@ internal class ChooseTokenListItemConverter( clickIntents.onAccountExpandClick(clickedAccount) } } + val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?) = if (isSearchingState) { + { _ -> FiatAmountState.Empty } + } else { + { _ -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative) } + } + val converter = AccountCryptoPortfolioItemStateConverter( appCurrency = appCurrency, account = account, onItemClick = onItemClick.takeIf { !isSearchingState }, priceChangeLce = this.priceChangeLce, + fiatAmountStateProvider = fiatAmountStateProvider, + subtitle2StateProvider = { _ -> null }, ) val accountItem = converter.convert(tokenList.totalFiatBalance) val tokenConverter = tokenStatusConverter(this) @@ -100,18 +111,14 @@ internal class ChooseTokenListItemConverter( return when (tokenList) { is TokenList.Empty -> TokenListUMData.EmptyList - is TokenList.GroupedByNetwork -> tokenList.toGroupedItems(tokenConverter).let { grouped -> - TokenListUMData.TokenList( - tokensList = grouped.toPersistentList(), - totalTokensCount = grouped.size, - ) - } - is TokenList.Ungrouped -> tokenList.toUngroupedItems(tokenConverter).let { ungrouped -> - TokenListUMData.TokenList( - tokensList = ungrouped.toPersistentList(), - totalTokensCount = ungrouped.size, - ) - } + is TokenList.GroupedByNetwork -> TokenListUMData.TokenList( + tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(), + totalTokensCount = tokenList.flattenCurrencies().size, + ) + is TokenList.Ungrouped -> TokenListUMData.TokenList( + tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(), + totalTokensCount = tokenList.flattenCurrencies().size, + ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt index 2eb003236d..0766482b98 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt @@ -5,8 +5,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountId @@ -19,13 +18,9 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.choosetoken.api.* import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarToggleTransformer import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarUpdateQueryTransformer -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenFullUM -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenUM -import com.tangem.feature.swap.choosetoken.impl.ui.WalletListUM +import com.tangem.feature.swap.choosetoken.impl.ui.* import com.tangem.feature.swap.converters.TokensDataConverter import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent @@ -121,10 +116,13 @@ internal class ChooseTokenModel @Inject constructor( TokensDataConverter( onSearchEntered = { query -> bridge.onSearchQuery(query) }, - onTokenClick = { tokenId -> - val selected = tokenId to ChooseTokenAnalyticsPayload - .IsSearched(isSearchingState) - bridge.onTokenSelected(selected) + onTokenClick = { account, cryptoCurrencyStatus -> + val result = ChooseTokenResultOld( + account = account, + cryptoCurrencyStatus = cryptoCurrencyStatus, + isSearched = searchQueryState.value.isNotEmpty(), + ) + bridge.onTokenSelected(result) }, onAccountClick = { account -> expandedAccountsFlow.update { expandedList -> @@ -147,41 +145,39 @@ internal class ChooseTokenModel @Inject constructor( @Suppress("LongMethod") private fun combineUI(): StateFlow = channelFlow { - val allWalletsFlow: StateFlow> = + val allWalletsFlow: StateFlow> = getWalletsUseCase.invokeAsMap().stateIn(this) + // todo swap add optional param, store, and GetSelectedWalletUseCase + val firstSelectedWallet = allWalletsFlow.value.values.first() val selectedWalletFlow: StateFlow = onWalletSelected.receiveAsFlow() .mapNotNull { walletId -> allWalletsFlow.value[walletId] } - .stateIn(this, SharingStarted.Eagerly, allWalletsFlow.value.values.first()) + .stateIn(this, SharingStarted.Eagerly, firstSelectedWallet) - val selectedWalletTokensData: Flow = combine( - flow = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), + val fullPortfolioBlockFlow = combine( + flow = allWalletsFlow, flow2 = portfolioListBlockDelegate.portfolioList, - transform = { selectedWalletId, allPortfoliosData -> allPortfoliosData[selectedWalletId] }, - ) - .filterNotNull() - .distinctUntilChanged() - - val walletListUmFlow = combine( - flow = selectedWalletFlow, - flow2 = allWalletsFlow, - transform = { selectedWallet, allWallets -> - allWallets.entries + flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), + transform = { allWallets, portfolioList, selectedWalletId -> + val tokensListData = portfolioList[selectedWalletId] ?: return@combine null + val walletsUM = allWallets.entries .map { (walletId, wallet) -> - val type = if (selectedWallet.walletId == walletId) { - TangemButtonType.Primary - } else { - TangemButtonType.Secondary - } - TangemButtonUM( + val searchResultCount: TextReference? = portfolioList[walletId]?.totalTokensCount + ?.toString() + ?.let(::stringReference) + ?.takeIf { isSearchingState } + WalletTabUM( text = stringReference(wallet.name), onClick = { onWalletSelected.trySend(walletId) }, - type = type, + isSelected = selectedWalletId == walletId, + count = searchResultCount, ) } + walletsUM to tokensListData }, ) + .filterNotNull() .distinctUntilChanged() portfolioListBlockDelegate.onTokenItemClick.receiveAsFlow() @@ -195,11 +191,10 @@ internal class ChooseTokenModel @Inject constructor( .launchIn(this) combine( - flow = selectedWalletTokensData, + flow = fullPortfolioBlockFlow, flow2 = settingContextUseCase.invoke(), flow3 = marketsStateFlow, - flow4 = walletListUmFlow, - transform = { tokensData, settings, marketsData, walletList -> + transform = { (walletList, tokensData), settings, marketsData -> val walletsUM = if (walletList.size != 1) { WalletListUM(walletList.toPersistentList()) } else { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt index 626a24fe4e..a02d3a52c2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt @@ -1,15 +1,23 @@ package com.tangem.feature.swap.choosetoken.impl.ui +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics +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.PreviewParameterProvider @@ -17,10 +25,6 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.tokens.portfolioTokensList import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.components.buttons.common.TangemButton -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition -import com.tangem.core.ui.components.buttons.common.TangemButtonSize -import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults @@ -34,8 +38,6 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -54,6 +56,18 @@ import kotlin.random.Random private const val LOAD_MORE_BUFFER = 25 +private val ChooseTokenUM.isNotFoundState: Boolean + get() = tokensListData.tokensList.isEmpty() && + isSearching && + marketsState !is SwapMarketState.Content && + marketsState !is SwapMarketState.Loading + +private val ChooseTokenUM.isEmptyState: Boolean + get() = tokensListData.tokensList.isEmpty() && + !isSearching && + marketsState !is SwapMarketState.Content && + marketsState !is SwapMarketState.Loading + @Composable internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { Column( @@ -93,7 +107,6 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { modifier = modifier .fillMaxSize() .nestedScroll(nestedScrollConnection), - horizontalAlignment = Alignment.CenterHorizontally, state = lazyListState, contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { @@ -108,20 +121,26 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { assetsTitle() if (state.contentUM != null) { - walletListItem(state.contentUM.walletList) + when { + state.contentUM.isNotFoundState -> tokensNotFound() + state.contentUM.isEmptyState -> emptyTokensList() + else -> { + walletListItem(state.contentUM.walletList) - tokensListItems( - tokensListData = state.contentUM.tokensListData, - isBalanceHidden = state.contentUM.isBalanceHidden, - ) + tokensListItems( + tokensListData = state.contentUM.tokensListData, + isBalanceHidden = state.contentUM.isBalanceHidden, + ) - if (state.contentUM.marketsState != null) { - item("markets_title_spacer") { SpacerH(height = 20.dp) } - swapMarketsListItems(state.contentUM.marketsState) + if (state.contentUM.marketsState != null) { + item("markets_title_spacer") { SpacerH(height = 20.dp) } + swapMarketsListItems(state.contentUM.marketsState) + } + } } } } - if (state.contentUM?.marketsState != null) { + if (state.contentUM?.marketsState != null && !state.contentUM.isNotFoundState && !state.contentUM.isEmptyState) { SetupMarketScrollTracker(state.contentUM.marketsState, lazyListState) } } @@ -183,26 +202,59 @@ private fun LazyListScope.assetsTitle() { private fun LazyListScope.walletListItem(walletList: WalletListUM) { if (walletList.items.isEmpty()) return item("wallet_list") { - Row( - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), + LazyRow( + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8), - verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(horizontal = 16.dp), ) { - walletList.items.forEach { um -> - val colors = when (um.type) { - TangemButtonType.Primary -> TangemButtonsDefaults.primaryButtonColors - else -> TangemButtonsDefaults.secondaryButtonColors - } - TangemButton( - text = um.text?.resolveReference().orEmpty(), - icon = TangemButtonIconPosition.None, - size = TangemButtonSize.Action, - colors = colors, - showProgress = false, - onClick = um.onClick, - enabled = true, + items(walletList.items) { um -> + WalletTabItem(um) + } + } + } +} + +@Composable +private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { + val isSelected = state.isSelected + val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary + val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1 + val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary + val countBackground = if (isSelected) { + TangemTheme.colors.button.secondary.copy(alpha = 0.2f) + } else { + TangemTheme.colors.button.primary.copy(alpha = 0.1f) + } + + Row( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .clickable(onClick = state.onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.text.resolveReference(), + color = buttonTextColor, + style = TangemTheme.typography.button, + ) + + if (state.count != null) { + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .background(countBackground, shape = CircleShape) + .defaultMinSize(minWidth = 20.dp) + .padding(horizontal = 4.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.count.resolveReference(), + color = countTextColor, + style = TangemTheme.typography.caption1, ) } } @@ -253,6 +305,58 @@ private fun LazyListScope.tokensList(items: ImmutableList, isB ) } +private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) { + item("EmptyTokensList") { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillParentMaxSize(), + ) { + Column(modifier = Modifier.align(Alignment.Center)) { + Image( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.ic_no_token_44), + colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), + contentDescription = null, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.CenterHorizontally), + text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + } + } +} + +private fun LazyListScope.tokensNotFound(modifier: Modifier = Modifier) { + item("TokensNotFound") { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillParentMaxSize(), + ) { + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing32) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.TopCenter), + text = stringResourceSafe(id = R.string.express_token_list_empty_search), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + } +} + @Preview @Composable private fun TokenScreenPreview(@PreviewParameter(ChooseTokenScreenPreviewProvider::class) state: ChooseTokenFullUM) { @@ -321,31 +425,42 @@ private val accounts private val wallets get() = persistentListOf( - TangemButtonUM( + WalletTabUM( text = TextReference.Str(value = "Wallet 1"), - type = TangemButtonType.Primary, + isSelected = true, onClick = {}, + count = null, ), - TangemButtonUM( + WalletTabUM( + text = TextReference.Str(value = "Wallet 1"), + isSelected = true, + onClick = {}, + count = stringReference("3"), + ), + WalletTabUM( text = TextReference.Str(value = "Wallet 2"), - type = TangemButtonType.Secondary, + isSelected = false, onClick = {}, + count = stringReference("333"), ), - TangemButtonUM( + WalletTabUM( text = TextReference.Str(value = "Wallet 3"), - type = TangemButtonType.Secondary, + isSelected = false, onClick = {}, + count = null, ), ) +private val initialUM = ChooseTokenInitialUM( + screenTitle = stringReference("Choose token"), + onCloseClick = {}, + searchBar = searchBar, +) + private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( ChooseTokenFullUM( - initialUM = ChooseTokenInitialUM( - screenTitle = stringReference("Choose token"), - onCloseClick = {}, - searchBar = searchBar, - ), + initialUM = initialUM, contentUM = ChooseTokenUM( walletList = WalletListUM(wallets), isBalanceHidden = false, @@ -357,5 +472,15 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider, + val items: ImmutableList, +) + +internal data class WalletTabUM( + val text: TextReference, + val count: TextReference?, + val isSelected: Boolean, + val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt index 0bb771929f..333bb5087c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt @@ -2,15 +2,21 @@ package com.tangem.feature.swap.converters import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount +import com.tangem.common.ui.R import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter import com.tangem.common.ui.account.TokensListPortfolioItemConverter +import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -29,34 +35,68 @@ internal class AccountTokenItemConverter( private val appCurrency: AppCurrency, private val unavailableErrorText: TextReference, private val expandedAccounts: Map, - private val onTokenItemClick: (String) -> Unit, - private val onAccountItemClick: (Account.CryptoPortfolio) -> Unit, + private val onTokenItemClick: (Account, CryptoCurrencyStatus) -> Unit, + private val onAccountItemClick: (Account) -> Unit, ) : Converter { override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio { - return TokensListPortfolioItemConverter( - tokenItemUM = AccountCryptoPortfolioItemStateConverter( + val headerTokenItemState = when (val account = value.account) { + is Account.CryptoPortfolio -> AccountCryptoPortfolioItemStateConverter( appCurrency = appCurrency, - account = value.account.copy( - cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency }, - ), + account = account.copy(cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency }), onItemClick = onAccountItemClick, ).convert( TotalFiatBalance.Loaded( amount = value.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() }, source = StatusSource.ONLY_CACHE, ), - ), + ) + is Account.Payment -> createPaymentAccountHeaderState(value) + } + return TokensListPortfolioItemConverter( + tokenItemUM = headerTokenItemState, isExpanded = expandedAccounts[value.account.accountId] != false, isCollapsable = true, tokens = value.currencyList.map { accountSwapCurrency -> - createAvailableItemConverter() + createAvailableItemConverter(value.account) .convert(accountSwapCurrency.cryptoCurrencyStatus) }.map(TokensListItemUM::Token).toPersistentList(), ).convert(Unit) } - fun createAvailableItemConverter(): TokenItemStateConverter { + private fun createPaymentAccountHeaderState(accountSwapAvailability: AccountSwapAvailability): TokenItemState { + val account = accountSwapAvailability.account + val tokensCount = accountSwapAvailability.currencyList.size + val fiatBalance = + accountSwapAvailability.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() } + return TokenItemState.Content( + id = account.accountId.value, + iconState = CurrencyIconState.PaymentAccount(), + titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + onItemClick = { onAccountItemClick(account) }, + fiatAmountState = FiatAmountState.Content( + text = fiatBalance.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + isFlickering = false, + ), + subtitle2State = null, + onItemLongClick = null, + ) + } + + fun createAvailableItemConverter(account: Account): TokenItemStateConverter { return TokenItemStateConverter( appCurrency = appCurrency, subtitleStateProvider = { status -> @@ -70,7 +110,7 @@ internal class AccountTokenItemConverter( fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true) }, - onItemClick = { account, currencyStatus -> onTokenItemClick(currencyStatus.currency.id.value) }, + onItemClick = { _, currencyStatus -> onTokenItemClick(account, currencyStatus) }, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt index 64ba7cf5b0..d06953f1a3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenListUMData @@ -16,10 +17,10 @@ import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class TokensDataConverter( - private val onSearchEntered: (String) -> Unit, - onTokenClick: (String) -> Unit, - onAccountClick: (Account.CryptoPortfolio) -> Unit, + onTokenClick: (Account, CryptoCurrencyStatus) -> Unit, + onAccountClick: (Account) -> Unit, private val expandedAccounts: Map, + private val onSearchEntered: (String) -> Unit, private val tokensDataState: CurrenciesGroup, private val isBalanceHidden: Boolean, private val isAccountsMode: Boolean, @@ -52,7 +53,7 @@ internal class TokensDataConverter( } else { val tokensList = accountList.flatMap { (_, currencyList) -> currencyList.asSequence().map { accountSwapCurrency -> - accountListItemConverter.createAvailableItemConverter() + accountListItemConverter.createAvailableItemConverter(accountSwapCurrency.account) .convert(accountSwapCurrency.cryptoCurrencyStatus) }.map(TokensListItemUM::Token).toPersistentList() }.toPersistentList() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index dcdc0a63a5..41203dc1f7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -33,7 +33,6 @@ import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore -import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase @@ -49,12 +48,14 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.derivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.pay.WithdrawalResult +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.promo.ShouldShowStoriesUseCase import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.usercountry.GetUserCountryUseCase @@ -144,6 +145,7 @@ internal class SwapModel @Inject constructor( private val appsFlyerStore: AppsFlyerStore, private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, private val messageSender: UiMessageSender, + private val paymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, chooseTokenBridgeFactory: ChooseTokenBridge.Factory, giveApprovalFeatureToggles: GiveApprovalFeatureToggles, ) : Model() { @@ -219,8 +221,10 @@ internal class SwapModel @Inject constructor( private val swapRouter: SwapRouter = SwapRouter(router = router) private var userCountry: UserCountry? = null - private var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null - private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null + private var fromAccount: Account? = null + private var toAccount: Account? = null + private var fromAccountStatus: CryptoCurrencyStatus? = null + private var toAccountStatus: CryptoCurrencyStatus? = null /** * If user came from Tangem Pay -> fromAccountCurrencyStatus == null @@ -286,8 +290,12 @@ internal class SwapModel @Inject constructor( .launchIn(modelScope) chooseTokenBridge.onTokenSelected.receiveAsFlow() - .onEach { (addedToken, isSearched) -> - onTokenSelect(addedToken, isSearched.value) + .onEach { result -> + onTokenSelect( + account = result.account, + cryptoCurrencyStatus = result.cryptoCurrencyStatus, + isSearched = result.isSearched, + ) } .launchIn(modelScope) @@ -322,22 +330,31 @@ internal class SwapModel @Inject constructor( userWalletId = userWalletId, currency = initialCurrencyFrom, ).getOrNull() + val fromPaymentAccountStatus = + paymentAccountCryptoCurrencyStatusUseCase(userWalletId, initialCurrencyFrom).getOrNull() val toAccountStatus = initialCurrencyTo?.let { currencyTo -> getAccountCurrencyStatusUseCase.invokeSync( userWalletId = userWalletId, currency = currencyTo, ).getOrNull() } + val toPaymentAccountStatus = initialCurrencyTo?.let { currencyTo -> + paymentAccountCryptoCurrencyStatusUseCase(userWalletId, currencyTo).getOrNull() + } + val fromAccount = fromAccountStatus?.account ?: fromPaymentAccountStatus?.first + val fromStatus = fromAccountStatus?.status ?: fromPaymentAccountStatus?.second - if (fromAccountStatus == null) { + if (fromAccount != null && fromStatus != null) { + this@SwapModel.fromAccount = fromAccount + this@SwapModel.fromAccountStatus = fromStatus + this@SwapModel.toAccount = toAccountStatus?.account ?: toPaymentAccountStatus?.first + this@SwapModel.toAccountStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second + this@SwapModel.initialFromStatus = fromStatus + this@SwapModel.initialToStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second + initTokens(isInitiallyReversed) + } else { showAlert() swapRouter.back() - } else { - fromAccountCurrencyStatus = fromAccountStatus - toAccountCurrencyStatus = toAccountStatus - initialFromStatus = fromAccountStatus.status - initialToStatus = toAccountStatus?.status - initTokens(isInitiallyReversed) } } else { val fromStatus = getFromStatus() @@ -405,7 +422,7 @@ internal class SwapModel @Inject constructor( updateTokensState(state) val (selectedCurrency, selectedAccount) = run { - var selectedAccountCurrency = toAccountCurrencyStatus + var selectedAccountCurrency = toAccountStatus if (selectedAccountCurrency == null) { val amountSwapCurrency = swapInteractor.getInitialCurrencyToSwap( @@ -415,14 +432,11 @@ internal class SwapModel @Inject constructor( ) if (amountSwapCurrency != null) { - selectedAccountCurrency = AccountCryptoCurrencyStatus( - account = amountSwapCurrency.account, - status = amountSwapCurrency.cryptoCurrencyStatus, - ) + selectedAccountCurrency = amountSwapCurrency.cryptoCurrencyStatus } } - selectedAccountCurrency?.status to selectedAccountCurrency?.account + selectedAccountCurrency to toAccount } val isApplied = applyInitialTokenChoice( @@ -534,7 +548,7 @@ internal class SwapModel @Inject constructor( private fun applyInitialTokenChoice( state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?, - selectedAccount: Account.CryptoPortfolio?, + selectedAccount: Account?, isReverseFromTo: Boolean, ): Boolean { // exceptional case @@ -555,9 +569,9 @@ internal class SwapModel @Inject constructor( } val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) { if (isOrderReversed.value) { - selectedAccount to requireNotNull(fromAccountCurrencyStatus).account + selectedAccount to fromAccount } else { - requireNotNull(fromAccountCurrencyStatus).account to selectedAccount + fromAccount to selectedAccount } } else { null to null @@ -600,9 +614,9 @@ internal class SwapModel @Inject constructor( private fun startLoadingQuotes( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -673,9 +687,9 @@ internal class SwapModel @Inject constructor( private fun loadQuotesTask( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -777,7 +791,7 @@ internal class SwapModel @Inject constructor( selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - hideFee = tangemPayInput?.isWithdrawal == true, + hideFee = isTangemPayWithdrawal(), ) } @@ -971,8 +985,9 @@ internal class SwapModel @Inject constructor( } val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) val fee = getSelectedFee() + val isTangemPayWithdrawal = isTangemPayWithdrawal() - if (fee == null && tangemPayInput?.isWithdrawal != true) { + if (fee == null && !isTangemPayWithdrawal) { TangemLogger.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}") showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) modelScope.launch { @@ -994,7 +1009,7 @@ internal class SwapModel @Inject constructor( includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = fee, expressOperationType = ExpressOperationType.SWAP, - isTangemPayWithdrawal = tangemPayInput?.isWithdrawal == true, + isTangemPayWithdrawal = isTangemPayWithdrawal, ) }.onSuccess { swapTransactionState -> when (swapTransactionState) { @@ -1252,11 +1267,12 @@ internal class SwapModel @Inject constructor( } @Suppress("LongMethod") - private fun onTokenSelect(id: String, isSearched: Boolean) { + private fun onTokenSelect(account: Account, cryptoCurrencyStatus: CryptoCurrencyStatus, isSearched: Boolean) { val tokens = dataState.tokensDataState ?: return - val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id) + val foundToken = cryptoCurrencyStatus + val foundAccount = account - foundToken?.currency?.symbol?.let { symbol -> + foundToken.currency.symbol.let { symbol -> analyticsEventHandler.send( SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = symbol), ) @@ -1270,108 +1286,90 @@ internal class SwapModel @Inject constructor( ) } - if (foundToken != null) { - val fromToken: CryptoCurrencyStatus - val fromAccount: Account.CryptoPortfolio? - val toToken: CryptoCurrencyStatus - val toAccount: Account.CryptoPortfolio? - if (isOrderReversed.value) { - fromToken = foundToken - fromAccount = foundAccount - toToken = initialFromStatus - toAccount = fromAccountCurrencyStatus?.account + val fromToken: CryptoCurrencyStatus + val fromAccount: Account? + val toToken: CryptoCurrencyStatus + val toAccount: Account? + if (isOrderReversed.value) { + fromToken = foundToken + fromAccount = foundAccount + toToken = initialFromStatus + toAccount = this.fromAccount - val newToken = fromToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = true, - ) - } else { - fromTokenBalanceJobHolder.cancel() - } + val newToken = fromToken.currency as? CryptoCurrency.Coin + if (newToken != null) { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = newToken, + isFromCurrency = true, + ) } else { - fromToken = initialFromStatus - fromAccount = fromAccountCurrencyStatus?.account - toToken = foundToken - toAccount = foundAccount - - val newToken = toToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = false, - ) - } else { - toTokenBalanceJobHolder.cancel() - } + fromTokenBalanceJobHolder.cancel() } - - if (dataState.fromCryptoCurrency != null && dataState.tokensDataState != null) { - isAmountChangedByUser = true - } - - dataState = dataState.copy( - fromCryptoCurrency = fromToken, - fromAccount = fromAccount, - toCryptoCurrency = toToken, - toAccount = toAccount, - selectedProvider = null, - ) - swapRouter.openScreen(SwapNavScreen.Main) - if (handleSwapNotSupported( - state = tokens, - fromToken = fromToken, - toToken = toToken, - fromAccount = fromAccount, - toAccount = toAccount, - ) - ) { - return - } - modelScope.launch { - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + - "isOrderReversed: ${isOrderReversed.value}", - ) - if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) { - uiState = stateBuilder.createInitialLoadingState( - initialCurrencyFrom = fromToken.currency, - initialCurrencyTo = toToken.currency, - fromNetworkInfo = fromToken.currency.getNetworkInfo(), - ) - } - updateFeePaidCryptoCurrencyFor(fromToken) - startLoadingQuotes( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromToken, toToken), - ) - } - updateTokensState(tokens) - } - } - - private fun getSelectedTokenAndAccount( - tokens: TokensDataStateExpress, - id: String, - ): Pair { - val accountCryptoCurrencyStatus = if (isOrderReversed.value) { - tokens.fromGroup } else { - tokens.toGroup - }.accountCurrencyList.firstNotNullOfOrNull { accountSwapAvailability -> - accountSwapAvailability.currencyList.firstOrNull { accountSwapCurrency -> - accountSwapCurrency.cryptoCurrencyStatus.currency.id.value == id + fromToken = initialFromStatus + fromAccount = this.fromAccount + toToken = foundToken + toAccount = foundAccount + + val newToken = toToken.currency as? CryptoCurrency.Coin + if (newToken != null) { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = newToken, + isFromCurrency = false, + ) + } else { + toTokenBalanceJobHolder.cancel() } } - return accountCryptoCurrencyStatus?.cryptoCurrencyStatus to accountCryptoCurrencyStatus?.account + + if (dataState.fromCryptoCurrency != null && dataState.tokensDataState != null) { + isAmountChangedByUser = true + } + + dataState = dataState.copy( + fromCryptoCurrency = fromToken, + fromAccount = fromAccount, + toCryptoCurrency = toToken, + toAccount = toAccount, + selectedProvider = null, + ) + swapRouter.openScreen(SwapNavScreen.Main) + if (handleSwapNotSupported( + state = tokens, + fromToken = fromToken, + toToken = toToken, + fromAccount = fromAccount, + toAccount = toAccount, + ) + ) { + return + } + modelScope.launch { + TangemLogger.i( + "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + + "isOrderReversed: ${isOrderReversed.value}", + ) + if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) { + uiState = stateBuilder.createInitialLoadingState( + initialCurrencyFrom = fromToken.currency, + initialCurrencyTo = toToken.currency, + fromNetworkInfo = fromToken.currency.getNetworkInfo(), + ) + } + updateFeePaidCryptoCurrencyFor(fromToken) + startLoadingQuotes( + fromToken = fromToken, + fromAccount = fromAccount, + toToken = toToken, + toAccount = toAccount, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + toProvidersList = findSwapProviders(fromToken, toToken), + ) + } + updateTokensState(tokens) } @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -1923,8 +1921,8 @@ internal class SwapModel @Inject constructor( state: TokensDataStateExpress, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, ): Boolean { val selectedCurrency = if (isOrderReversed.value) fromToken else toToken if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false @@ -1972,8 +1970,12 @@ internal class SwapModel @Inject constructor( } } + private fun isTangemPayWithdrawal(): Boolean { + return tangemPayInput?.isWithdrawal == true || dataState.fromAccount is Account.Payment + } + private fun List.filterForTangemPayWithdrawal(): List { - return if (tangemPayInput?.isWithdrawal == true) { + return if (isTangemPayWithdrawal()) { filter { it.type == ExchangeProviderType.CEX } } else { this diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index f352427c56..1c031215dd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -15,8 +15,8 @@ data class SwapProcessDataState( val fromCryptoCurrency: CryptoCurrencyStatus? = null, val toCryptoCurrency: CryptoCurrencyStatus? = null, val feePaidCryptoCurrency: CryptoCurrencyStatus? = null, - val fromAccount: Account.CryptoPortfolio? = null, - val toAccount: Account.CryptoPortfolio? = null, + val fromAccount: Account? = null, + val toAccount: Account? = null, // Amount from input val amount: String? = null, val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 6f704854d9..0ebbb39c1e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.ui import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM @@ -177,8 +178,8 @@ internal class StateBuilder( uiStateHolder: SwapStateHolder, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, mainTokenId: String, ): SwapStateHolder { val canSelectSendToken = mainTokenId != fromToken.currency.id.value @@ -241,8 +242,8 @@ internal class StateBuilder( fromToken: CryptoCurrency, toToken: CryptoCurrency, mainTokenId: String, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, ): SwapStateHolder { val canSelectSendToken = mainTokenId != fromToken.id.value val canSelectReceiveToken = mainTokenId != toToken.id.value @@ -501,7 +502,7 @@ internal class StateBuilder( swapProvider: SwapProvider, fromToken: TokenSwapInfo, toToken: CryptoCurrencyStatus?, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, isReverseSwapPossible: Boolean, @@ -618,7 +619,7 @@ internal class StateBuilder( emptyAmountState: SwapState.EmptyAmountState, fromTokenStatus: CryptoCurrencyStatus, toTokenStatus: CryptoCurrencyStatus?, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, isReverseSwapPossible: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -693,7 +694,7 @@ internal class StateBuilder( amountFormatted: String, amountRaw: String, fromToken: CryptoCurrency, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState @@ -1327,30 +1328,37 @@ internal class StateBuilder( return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId) } - private fun getFromCardAccountTitle(fromAccount: Account.CryptoPortfolio?): AccountTitleUM { + private fun getFromCardAccountTitle(fromAccount: Account?): AccountTitleUM { return if (fromAccount != null && isAccountsModeProvider()) { AccountTitleUM.Account( prefixText = resourceReference(R.string.common_from), name = fromAccount.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(fromAccount.icon), + icon = fromAccount.toIconUM(), ) } else { AccountTitleUM.Text(resourceReference(R.string.swapping_from_title)) } } - private fun getToCardAccountTitle(toAccount: Account.CryptoPortfolio?): AccountTitleUM { + private fun getToCardAccountTitle(toAccount: Account?): AccountTitleUM { return if (toAccount != null && isAccountsModeProvider()) { AccountTitleUM.Account( prefixText = resourceReference(R.string.common_to), name = toAccount.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(toAccount.icon), + icon = toAccount.toIconUM(), ) } else { AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)) } } + private fun Account.toIconUM(): AccountIconUM { + return when (this) { + is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon) + is Account.Payment -> AccountIconUM.Payment + } + } + private fun getChangeCardsButtonState(isReverseSwapPossible: Boolean) = if (isReverseSwapPossible) { ChangeCardsButtonState.ENABLED } else { diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index bf95156d7d..5bd49223ea 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tester.presentation.storybook.entity import com.tangem.core.ui.ds.badge.TangemBadgeColor import com.tangem.core.ui.ds.field.search.TangemFieldShape import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.topbar.TangemTopBarType internal sealed interface StoryBookPage @@ -64,4 +65,22 @@ internal data class TangemSearchFieldStory( internal data class TypographyStory( val isFontScaleDefault: Boolean, val onFontScaleToggle: () -> Unit, -) : StoryBookPage \ No newline at end of file +) : StoryBookPage + +internal data class TangemTopBarStory( + val selectedType: TangemTopBarType, + val onTypeChange: (TangemTopBarType) -> Unit, +) : StoryBookPage + +internal data class TangemTabStory( + val checkedIndex: Int, + val onCheckedIndexChange: (Int) -> Unit, +) : StoryBookPage + +internal data object TangemPagerIndicatorStory : StoryBookPage + +internal data object PlaceholderStory : StoryBookPage + +internal data object ProgressIndicatorStory : StoryBookPage + +internal data object DeviceIconStory : StoryBookPage \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt index c3c6cea2a1..c158b5f7d1 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt @@ -139,12 +139,18 @@ private fun BadgeShapeGroup(size: TangemBadgeSize, shape: TangemBadgeShape, colo @Composable private fun ColumnHeaderRow() { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.width(STATE_LABEL_WIDTH.dp)) Text( - text = "Text + Icon", + text = "Icon Start", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.weight(1f), + ) + Text( + text = "Icon End", style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.weight(1f), @@ -172,7 +178,7 @@ private fun BadgeTypeRow( type: TangemBadgeType, ) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -196,6 +202,21 @@ private fun BadgeTypeRow( onClick = {}, ) } + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(1f), + ) { + TangemBadge( + text = stringReference("New"), + tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_information_24), + size = size, + shape = shape, + color = color, + type = type, + iconPosition = TangemBadgeIconPosition.End, + onClick = {}, + ) + } Box( contentAlignment = Alignment.CenterStart, modifier = Modifier.weight(1f), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt index d1bd85a0e6..f4c2e96d65 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt @@ -32,7 +32,7 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { .background(TangemTheme.colors2.surface.level1), ) { item("primary") { - ButtonSection(title = "Primary") { isEnabled, text, shape -> + ButtonSection(title = "Primary") { isEnabled, isLoading, text, shape, iconPosition -> PrimaryTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -46,14 +46,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("secondary") { - ButtonSection(title = "Secondary") { isEnabled, text, shape -> + ButtonSection(title = "Secondary") { isEnabled, isLoading, text, shape, iconPosition -> SecondaryTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -67,8 +69,10 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } @@ -77,7 +81,7 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { ButtonSection( title = "PrimaryInverse", background = TangemTheme.colors2.surface.level2, - ) { isEnabled, text, shape -> + ) { isEnabled, isLoading, text, shape, iconPosition -> PrimaryInverseTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -91,14 +95,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("outline") { - ButtonSection(title = "Outline") { isEnabled, text, shape -> + ButtonSection(title = "Outline") { isEnabled, isLoading, text, shape, iconPosition -> OutlineTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -112,14 +118,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("accent") { - ButtonSection(title = "Accent") { isEnabled, text, shape -> + ButtonSection(title = "Accent") { isEnabled, isLoading, text, shape, iconPosition -> StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -133,14 +141,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("positive") { - ButtonSection(title = "Positive") { isEnabled, text, shape -> + ButtonSection(title = "Positive") { isEnabled, isLoading, text, shape, iconPosition -> StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -154,15 +164,17 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, type = TangemButtonType.Positive, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("ghost") { - ButtonSection(title = "Ghost") { isEnabled, text, shape -> + ButtonSection(title = "Ghost") { isEnabled, isLoading, text, shape, iconPosition -> GhostTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -176,21 +188,74 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } + item("sizes") { + SizeShowcase() + } } } +@Composable +private fun SizeShowcase() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = "Sizes", + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + TangemButtonSize.entries.forEach { size -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = size.name, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.width(STATE_LABEL_WIDTH.dp), + ) + PrimaryTangemButton( + onClick = {}, + text = stringReference("Button"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_tangem_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primaryInverted }, + ), + size = size, + ) + } + } + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + @Composable private fun ButtonSection( title: String, background: Color = TangemTheme.colors2.surface.level1, shapes: List = TangemButtonShape.entries, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -205,7 +270,9 @@ private fun ButtonSection( color = TangemTheme.colors.text.primary1, ) shapes.forEach { shape -> - ShapeGroup(shape = shape, button = button) + TangemButtonIconPosition.entries.forEach { iconPosition -> + ShapeGroup(shape = shape, iconPosition = iconPosition, button = button) + } } } HorizontalDivider( @@ -217,17 +284,46 @@ private fun ButtonSection( @Composable private fun ShapeGroup( shape: TangemButtonShape, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + iconPosition: TangemButtonIconPosition, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = shape.name, + text = "${shape.name} / Icon ${iconPosition.name}", style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, ) ColumnHeaderRow() - StateRow(isEnabled = true, shape = shape, button = button) - StateRow(isEnabled = false, shape = shape, button = button) + StateRow( + label = "Enabled", + isEnabled = true, + isLoading = false, + shape = shape, + iconPosition = iconPosition, + button = button, + ) + StateRow( + label = "Disabled", + isEnabled = false, + isLoading = false, + shape = shape, + iconPosition = iconPosition, + button = button, + ) + StateRow( + label = "Loading", + isEnabled = true, + isLoading = true, + shape = shape, + iconPosition = iconPosition, + button = button, + ) } } @@ -253,27 +349,37 @@ private fun ColumnHeaderRow() { } } +@Suppress("LongParameterList") @Composable private fun StateRow( + label: String, isEnabled: Boolean, + isLoading: Boolean, shape: TangemButtonShape, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + iconPosition: TangemButtonIconPosition, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (isEnabled) "Enabled" else "Disabled", + text = label, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.width(STATE_LABEL_WIDTH.dp), ) Box(modifier = Modifier.weight(1f)) { - button(isEnabled, true, shape) + button(isEnabled, isLoading, true, shape, iconPosition) } Box(modifier = Modifier.weight(1f)) { - button(isEnabled, false, shape) + button(isEnabled, isLoading, false, shape, iconPosition) } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt new file mode 100644 index 0000000000..aaa3b8fc51 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.deviceicon + +import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val deviceIconStoryFactory: StoryPageFactory = + StoryPageFactory { DeviceIconStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt new file mode 100644 index 0000000000..0c3708b35d --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt @@ -0,0 +1,184 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.deviceicon + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.image.TangemDeviceIcon +import com.tangem.core.ui.res.TangemTheme + +private val CardBlue = Color(0xFF1C5FBF) +private val CardGold = Color(0xFFD4A017) +private val CardPurple = Color(0xFF7B2FBE) +private val RingGreen = Color(0xFF2ECC71) + +@Composable +internal fun DeviceIconStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_cards") { + SectionTitle(text = "Cards") + } + + item("card_1") { + DeviceIconRow( + label = "Single card", + state = DeviceIconUM.Card(mainColor = CardBlue, secondColor = null), + ) + } + + item("card_2") { + DeviceIconRow( + label = "Two cards", + state = DeviceIconUM.Card(mainColor = CardBlue, secondColor = CardGold), + ) + } + + item("card_3") { + DeviceIconRow( + label = "Three cards", + state = DeviceIconUM.Card( + mainColor = CardBlue, + secondColor = CardGold, + thirdColor = CardPurple, + ), + ) + } + + item("section_rings") { + SectionTitle(text = "Rings") + } + + item("ring_solo") { + DeviceIconRow( + label = "Ring only", + state = DeviceIconUM.Ring(mainColor = RingGreen), + ) + } + + item("ring_card") { + DeviceIconRow( + label = "Ring + card", + state = DeviceIconUM.Ring(mainColor = RingGreen, cardColor = CardBlue), + ) + } + + item("ring_two_cards") { + DeviceIconRow( + label = "Ring + 2 cards", + state = DeviceIconUM.Ring( + mainColor = RingGreen, + cardColor = CardBlue, + secondCardColor = CardGold, + ), + ) + } + + item("section_stubs") { + SectionTitle(text = "Stubs") + } + + repeat(3) { count -> + item("stub_$count") { + DeviceIconRow( + label = "Stub ($count card${if (count > 0) "s" else ""})", + state = DeviceIconUM.Stub(cardsCount = count), + ) + } + } + + item("section_mobile") { + SectionTitle(text = "Mobile") + } + + item("mobile") { + DeviceIconRow( + label = "Mobile wallet", + state = DeviceIconUM.Mobile, + ) + } + + item("section_sizes") { + SectionTitle(text = "Sizes") + } + + item("sizes") { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Bottom, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + listOf(24, 32, 40, 48).forEach { size -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TangemDeviceIcon( + state = DeviceIconUM.Card( + mainColor = CardBlue, + secondColor = CardGold, + thirdColor = CardPurple, + ), + modifier = Modifier.size(size.dp), + ) + Text( + text = "${size}dp", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun DeviceIconRow(label: String, state: DeviceIconUM) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + TangemDeviceIcon( + state = state, + modifier = Modifier.size(40.dp), + ) + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt index 95a5c349d7..f034c7b02f 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt @@ -88,6 +88,29 @@ private fun buildSampleRows(): List = listOf( title = stringReference("Account"), subtitle = stringReference("\$ 42,900.17"), ), + TangemHeaderRowUM( + id = "tail_text", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Text(text = stringReference("12 tokens")), + title = stringReference("Account"), + subtitle = stringReference("\$ 42,900.17"), + ), + TangemHeaderRowUM( + id = "tail_draggable", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Draggable(iconRes = R.drawable.ic_drag_24), + title = stringReference("Account"), + subtitle = stringReference("\$ 42,900.17"), + ), + TangemHeaderRowUM( + id = "clickable", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Icon(R.drawable.ic_arrow_collapse_24), + title = stringReference("Clickable row"), + subtitle = stringReference("\$ 42,900.17"), + isEnabled = true, + onItemClick = {}, + ), TangemHeaderRowUM( id = "title_only", title = stringReference("Account"), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt new file mode 100644 index 0000000000..22832e4de4 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.pagerindicator + +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory +import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory + +internal val tangemPagerIndicatorStoryFactory: StoryPageFactory = + StoryPageFactory { TangemPagerIndicatorStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt new file mode 100644 index 0000000000..fc770c4015 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt @@ -0,0 +1,142 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.pagerindicator + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.TangemPagerIndicator +import com.tangem.core.ui.ds.TangemPagerIndicatorColors +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun TangemPagerIndicatorStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_page_counts") { + SectionTitle(text = "Page counts") + } + + listOf(1, 2, 3, 4, 5).forEach { pageCount -> + item("count_$pageCount") { + IndicatorRow(label = "$pageCount page(s)", pageCount = pageCount, currentPage = 0) + } + } + + item("section_many_pages") { + SectionTitle(text = "Many pages (>5)") + } + + item("many_start") { + IndicatorRow(label = "7 pages, at start", pageCount = 7, currentPage = 0) + } + + item("many_middle") { + IndicatorRow(label = "7 pages, at middle", pageCount = 7, currentPage = 3) + } + + item("many_end") { + IndicatorRow(label = "7 pages, at end", pageCount = 7, currentPage = 6) + } + + item("ten_start") { + IndicatorRow(label = "10 pages, at start", pageCount = 10, currentPage = 0) + } + + item("ten_middle") { + IndicatorRow(label = "10 pages, at middle", pageCount = 10, currentPage = 5) + } + + item("ten_end") { + IndicatorRow(label = "10 pages, at end", pageCount = 10, currentPage = 9) + } + + item("section_active_positions") { + SectionTitle(text = "Active dot positions (5 pages)") + } + + repeat(4) { page -> + item("active_$page") { + IndicatorRow(label = "Active: page ${page + 1}", pageCount = 5, currentPage = page) + } + } + + item("section_overlay") { + SectionTitle(text = "With overlay background") + } + + item("overlay") { + IndicatorRowWithOverlay(pageCount = 5, currentPage = 2) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun IndicatorRow(label: String, pageCount: Int, currentPage: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemPagerIndicator( + pagerState = rememberPagerState(currentPage) { pageCount }, + ) + } +} + +@Composable +private fun IndicatorRowWithOverlay(pageCount: Int, currentPage: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = "With overlay", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemPagerIndicator( + pagerState = rememberPagerState(currentPage) { pageCount }, + colors = TangemPagerIndicatorColors.copy( + overlay = TangemTheme.colors2.tabs.backgroundSecondary, + ), + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt new file mode 100644 index 0000000000..947666e5a7 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.placeholder + +import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val placeholderStoryFactory: StoryPageFactory = + StoryPageFactory { PlaceholderStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt new file mode 100644 index 0000000000..5eb7aa7d41 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt @@ -0,0 +1,254 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.placeholder + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.ChipShimmer +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun PlaceholderStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_rectangle") { + SectionTitle(text = "RectangleShimmer") + } + + item("rect_default") { + ShimmerRow(label = "Default") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + ) + } + } + + item("rect_narrow") { + ShimmerRow(label = "Narrow (40%)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth(fraction = 0.4f) + .height(16.dp), + ) + } + } + + item("rect_tall") { + ShimmerRow(label = "Tall (48dp)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + ) + } + } + + item("rect_custom_radius") { + ShimmerRow(label = "Custom radius (16dp)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + radius = 16.dp, + ) + } + } + + item("section_circle") { + SectionTitle(text = "CircleShimmer") + } + + item("circle_sizes") { + ShimmerRow(label = "Sizes: 24, 32, 40, 48") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircleShimmer(modifier = Modifier.size(24.dp)) + CircleShimmer(modifier = Modifier.size(32.dp)) + CircleShimmer(modifier = Modifier.size(40.dp)) + CircleShimmer(modifier = Modifier.size(48.dp)) + } + } + } + + item("section_text") { + SectionTitle(text = "TextShimmer") + } + + item("text_title") { + ShimmerRow(label = "titleRegular44") { + TextShimmer( + style = TangemTheme.typography2.titleRegular44, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + ) + } + } + + item("text_heading") { + ShimmerRow(label = "headingSemibold22") { + TextShimmer( + style = TangemTheme.typography2.headingSemibold22, + modifier = Modifier.fillMaxWidth(fraction = 0.6f), + ) + } + } + + item("text_body") { + ShimmerRow(label = "bodyRegular16") { + TextShimmer( + style = TangemTheme.typography2.bodyRegular16, + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + ) + } + } + + item("text_caption") { + ShimmerRow(label = "captionRegular12") { + TextShimmer( + style = TangemTheme.typography2.captionRegular12, + modifier = Modifier.fillMaxWidth(fraction = 0.4f), + ) + } + } + + item("text_size_height") { + ShimmerRow(label = "bodyRegular16 (textSizeHeight)") { + TextShimmer( + style = TangemTheme.typography2.bodyRegular16, + textSizeHeight = true, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + ) + } + } + + item("section_button_chip") { + SectionTitle(text = "SmallButtonShimmer & ChipShimmer") + } + + item("small_button") { + ShimmerRow(label = "SmallButtonShimmer") { + SmallButtonShimmer() + } + } + + item("small_button_icon") { + ShimmerRow(label = "SmallButtonShimmer (with icon)") { + SmallButtonShimmer(withIcon = true) + } + } + + item("chip") { + ShimmerRow(label = "ChipShimmer") { + ChipShimmer() + } + } + + item("section_composition") { + SectionTitle(text = "Skeleton composition") + } + + item("card_skeleton") { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = "Typical card loading state", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircleShimmer(modifier = Modifier.size(40.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextShimmer( + style = TangemTheme.typography2.headingSemibold17, + modifier = Modifier.width(120.dp), + ) + TextShimmer( + style = TangemTheme.typography2.captionRegular13, + modifier = Modifier.width(80.dp), + ) + } + } + } + } + + item("list_skeleton") { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = "List loading state", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + repeat(3) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RectangleShimmer(modifier = Modifier.size(40.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + TextShimmer( + style = TangemTheme.typography2.bodyMedium16, + modifier = Modifier.fillMaxWidth(fraction = 0.6f), + ) + TextShimmer( + style = TangemTheme.typography2.captionRegular13, + modifier = Modifier.fillMaxWidth(fraction = 0.4f), + ) + } + } + } + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun ShimmerRow(label: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + content() + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt new file mode 100644 index 0000000000..9855967b04 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.progress + +import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val progressIndicatorStoryFactory: StoryPageFactory = + StoryPageFactory { ProgressIndicatorStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt new file mode 100644 index 0000000000..85ef01a6ea --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt @@ -0,0 +1,132 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.progress + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.progress.TangemLinearProgressIndicatorWithDot +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun ProgressIndicatorStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_progress") { + SectionTitle(text = "Progress values") + } + + listOf(0f, 0.25f, 0.5f, 0.75f, 1f).forEach { progress -> + item("progress_$progress") { + ProgressRow(label = "${(progress * 100).toInt()}%", progress = progress) + } + } + + item("section_heights") { + SectionTitle(text = "Track heights") + } + + listOf(4, 6, 8).forEach { height -> + item("height_$height") { + ProgressRow(label = "${height}dp track", progress = 0.5f, height = height) + } + } + + item("section_colors") { + SectionTitle(text = "Color variants") + } + + item("accent") { + ProgressRowWithColors( + label = "Accent", + progress = 0.6f, + dotColor = TangemTheme.colors2.fill.status.accent, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + + item("warning") { + ProgressRowWithColors( + label = "Warning", + progress = 0.4f, + dotColor = TangemTheme.colors2.fill.status.warning, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + + item("attention") { + ProgressRowWithColors( + label = "Attention", + progress = 0.8f, + dotColor = TangemTheme.colors2.fill.status.attention, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun ProgressRow(label: String, progress: Float, height: Int = 6) { + ProgressRowWithColors( + label = label, + progress = progress, + height = height, + dotColor = TangemTheme.colors2.fill.status.accent, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) +} + +@Composable +private fun ProgressRowWithColors( + label: String, + progress: Float, + dotColor: androidx.compose.ui.graphics.Color, + bgColor: androidx.compose.ui.graphics.Color, + height: Int = 6, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemLinearProgressIndicatorWithDot( + progress = { progress }, + dotColor = dotColor, + backgroundColor = bgColor, + modifier = Modifier + .fillMaxWidth() + .height(height.dp), + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt new file mode 100644 index 0000000000..21b9a31e14 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt @@ -0,0 +1,19 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.tab + +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemTabStory { + return TangemTabStory( + checkedIndex = 0, + onCheckedIndexChange = { index -> + updateStory { it.copy(checkedIndex = index) } + }, + ) +} + +internal val tangemTabStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt new file mode 100644 index 0000000000..f734c1027c --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt @@ -0,0 +1,127 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.tab + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.tabs.TangemTab +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory + +@Composable +internal fun TangemTabStory(state: TangemTabStory, modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("interactive") { + TabSection(title = "Interactive") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("Markets", "Portfolio", "Activity").forEachIndexed { index, label -> + TangemTab( + text = stringReference(label), + isChecked = state.checkedIndex == index, + onCheckedChange = { if (it) state.onCheckedIndexChange(index) }, + ) + } + } + } + } + + item("checked") { + TabSection(title = "Checked") { + TangemTab( + text = stringReference("Markets"), + isChecked = true, + onCheckedChange = {}, + ) + } + } + + item("unchecked") { + TabSection(title = "Unchecked") { + TangemTab( + text = stringReference("Markets"), + isChecked = false, + onCheckedChange = {}, + ) + } + } + + item("disabled_checked") { + TabSection(title = "Disabled (Checked)") { + TangemTab( + text = stringReference("Markets"), + isChecked = true, + onCheckedChange = {}, + isEnabled = false, + ) + } + } + + item("disabled_unchecked") { + TabSection(title = "Disabled (Unchecked)") { + TangemTab( + text = stringReference("Markets"), + isChecked = false, + onCheckedChange = {}, + isEnabled = false, + ) + } + } + + item("multiple_tabs") { + TabSection(title = "Multiple tabs row") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TangemTab( + text = stringReference("All"), + isChecked = true, + onCheckedChange = {}, + ) + TangemTab( + text = stringReference("Gainers"), + isChecked = false, + onCheckedChange = {}, + ) + TangemTab( + text = stringReference("Losers"), + isChecked = false, + onCheckedChange = {}, + ) + } + } + } + } +} + +@Composable +private fun TabSection(title: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt new file mode 100644 index 0000000000..494ab36f6b --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt @@ -0,0 +1,20 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.topbar + +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemTopBarStory { + return TangemTopBarStory( + selectedType = TangemTopBarType.Default, + onTypeChange = { type -> + updateStory { it.copy(selectedType = type) } + }, + ) +} + +internal val tangemTopBarStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt new file mode 100644 index 0000000000..4a0d026c91 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt @@ -0,0 +1,259 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.topbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun TangemTopBarStory(state: TangemTopBarStory, modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + stickyHeader("type_toggle") { + TypeToggle( + selected = state.selectedType, + onSelect = state.onTypeChange, + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + item("title_only") { + TopBarVariant(label = "Title only") { + TangemTopBar( + title = stringReference("Wallet"), + type = state.selectedType, + startAction = null, + ) + } + } + + item("title_subtitle") { + TopBarVariant(label = "Title + Subtitle") { + TangemTopBar( + title = stringReference("Wallet"), + subtitle = stringReference("3 cards"), + type = state.selectedType, + startAction = null, + ) + } + } + + item("back_action") { + TopBarVariant(label = "Back action + Title") { + TangemTopBar( + title = stringReference("Send"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + ) + } + } + + item("back_and_end") { + TopBarVariant(label = "Back + Title + End action") { + TangemTopBar( + title = stringReference("Token Details"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + onClick = {}, + ), + ), + ) + } + } + + item("back_and_two_end") { + TopBarVariant(label = "Back + Title + 2 End actions") { + TangemTopBar( + title = stringReference("Settings"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_information_24, + onClick = {}, + ), + TangemTopBarActionUM( + iconRes = R.drawable.ic_close_24, + onClick = {}, + ), + ), + ) + } + } + + item("ghost_actions") { + TopBarVariant(label = "Ghost mode actions (progress=1)") { + TangemTopBar( + title = stringReference("Portfolio"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_tangem_24, + onClick = {}, + ghostModeProgress = 1f, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + onClick = {}, + ghostModeProgress = 1f, + ), + ), + ) + } + } + + item("non_actionable") { + TopBarVariant(label = "Non-actionable icons") { + TangemTopBar( + title = stringReference("Details"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_tangem_24, + isActionable = false, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + isActionable = false, + ), + ), + ) + } + } + + item("title_icon") { + TopBarVariant(label = "Title with icon") { + TangemTopBar( + title = stringReference("Wallet"), + titleIconRes = R.drawable.ic_tangem_24, + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + ) + } + } + + item("no_title") { + TopBarVariant(label = "No title (end action only)") { + TangemTopBar( + type = state.selectedType, + startAction = null, + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_close_24, + onClick = {}, + ), + ), + ) + } + } + } +} + +@Composable +private fun TypeToggle( + selected: TangemTopBarType, + onSelect: (TangemTopBarType) -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(50) + Row( + modifier = modifier + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border(width = 1.dp, color = TangemTheme.colors2.border.neutral.secondary, shape = shape) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + TangemTopBarType.entries.forEach { type -> + TypeChip( + label = type.name, + selected = type == selected, + onClick = { onSelect(type) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun TypeChip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background(if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun TopBarVariant(label: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + content() + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt index 3a8b75942d..26d7f561c6 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt @@ -20,12 +20,18 @@ import com.tangem.feature.tester.presentation.storybook.page.badge.tangemBadgeSt import com.tangem.feature.tester.presentation.storybook.page.buttons.buttonsStoryFactory import com.tangem.feature.tester.presentation.storybook.page.checkbox.tangemCheckboxStoryFactory import com.tangem.feature.tester.presentation.storybook.page.contextmenu.tangemContextMenuStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.deviceicon.deviceIconStoryFactory import com.tangem.feature.tester.presentation.storybook.page.headerrow.tangemHeaderRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.message.tangemMessageStoryFactory import com.tangem.feature.tester.presentation.storybook.page.opportunities.opportunitiesBGStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.pagerindicator.tangemPagerIndicatorStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.placeholder.placeholderStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.progress.progressIndicatorStoryFactory import com.tangem.feature.tester.presentation.storybook.page.searchfield.tangemSearchFieldStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.tab.tangemTabStoryFactory import com.tangem.feature.tester.presentation.storybook.page.tabs.tangemSegmentedPickerStoryFactory import com.tangem.feature.tester.presentation.storybook.page.tokenrow.tangemTokenRowStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.topbar.tangemTopBarStoryFactory import com.tangem.feature.tester.presentation.storybook.page.typography.typographyStoryFactory private data class StoryItem(val title: String, val factory: StoryPageFactory) @@ -43,6 +49,12 @@ private fun buildStories() = listOf( StoryItem(title = "📋 Context Menu", factory = tangemContextMenuStoryFactory), StoryItem(title = "🔍 Search Field", factory = tangemSearchFieldStoryFactory), StoryItem(title = "🔤 Typography", factory = typographyStoryFactory), + StoryItem(title = "🧭 Top Bar", factory = tangemTopBarStoryFactory), + StoryItem(title = "🔀 Tab", factory = tangemTabStoryFactory), + StoryItem(title = "⚫ Pager Indicator", factory = tangemPagerIndicatorStoryFactory), + StoryItem(title = "💀 Placeholder", factory = placeholderStoryFactory), + StoryItem(title = "⏳ Progress Indicator", factory = progressIndicatorStoryFactory), + StoryItem(title = "💳 Device Icon", factory = deviceIconStoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index e8e17c0645..7bce52bb28 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -5,8 +5,11 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.feature.tester.presentation.storybook.entity.ButtonsStory +import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory import com.tangem.feature.tester.presentation.storybook.entity.NorthernLightsStory import com.tangem.feature.tester.presentation.storybook.entity.OpportunitiesBGStory +import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeStory import com.tangem.feature.tester.presentation.storybook.entity.StoryBookUM import com.tangem.feature.tester.presentation.storybook.entity.StoryList @@ -14,23 +17,33 @@ import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxSto import com.tangem.feature.tester.presentation.storybook.entity.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory import com.tangem.feature.tester.presentation.storybook.entity.TangemSegmentedPickerStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory import com.tangem.feature.tester.presentation.storybook.entity.TypographyStory import com.tangem.feature.tester.presentation.storybook.page.background.NorthernLightsStory import com.tangem.feature.tester.presentation.storybook.page.badge.TangemBadgeStory import com.tangem.feature.tester.presentation.storybook.page.buttons.ButtonsStory +import com.tangem.feature.tester.presentation.storybook.page.deviceicon.DeviceIconStory import com.tangem.feature.tester.presentation.storybook.page.opportunities.OpportunitiesBGStory import com.tangem.feature.tester.presentation.storybook.page.checkbox.TangemCheckboxStory import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory +import com.tangem.feature.tester.presentation.storybook.page.pagerindicator.TangemPagerIndicatorStory +import com.tangem.feature.tester.presentation.storybook.page.placeholder.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.page.progress.ProgressIndicatorStory +import com.tangem.feature.tester.presentation.storybook.page.tab.TangemTabStory import com.tangem.feature.tester.presentation.storybook.page.tabs.TangemSegmentedPickerStory import com.tangem.feature.tester.presentation.storybook.page.tokenrow.TangemTokenRowStory +import com.tangem.feature.tester.presentation.storybook.page.topbar.TangemTopBarStory import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.page.contextmenu.TangemContextMenuStory import com.tangem.feature.tester.presentation.storybook.page.searchfield.TangemSearchFieldStory import com.tangem.feature.tester.presentation.storybook.page.typography.TypographyStory +@Suppress("CyclomaticComplexMethod") @Composable internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) { BackHandler(onBack = state.onBackClick) @@ -54,6 +67,12 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemContextMenuStory -> TangemContextMenuStory(state = storyState) is TangemSearchFieldStory -> TangemSearchFieldStory(state = storyState) is TypographyStory -> TypographyStory(state = storyState) + is TangemTopBarStory -> TangemTopBarStory(state = storyState) + is TangemTabStory -> TangemTabStory(state = storyState) + TangemPagerIndicatorStory -> TangemPagerIndicatorStory() + PlaceholderStory -> PlaceholderStory() + ProgressIndicatorStory -> ProgressIndicatorStory() + DeviceIconStory -> DeviceIconStory() } } } \ No newline at end of file diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 51a9203ba8..36bed031e2 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -68,6 +68,8 @@ dependencies { implementation(projects.domain.balanceHiding.models) implementation(projects.domain.card) implementation(projects.domain.demo) + implementation(projects.domain.dynamicAddresses) + implementation(projects.domain.dynamicAddresses.models) implementation(projects.domain.markets.models) implementation(projects.domain.models) implementation(projects.domain.notifications.models) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index a8f037db6d..035e4511bf 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -24,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetails import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -148,6 +149,10 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( cloreMigrationModel = model.cloreMigrationModel, onDismiss = model.bottomSheetNavigation::dismiss, ) + is TokenDetailsBottomSheetConfig.DynamicAddresses -> DynamicAddressesBottomSheetComponent( + dynamicAddressesDelegate = model.dynamicAddressesDelegate, + onDismiss = model.bottomSheetNavigation::dismiss, + ) } @AssistedFactory diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt new file mode 100644 index 0000000000..e9fe02cce1 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt @@ -0,0 +1,125 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import com.tangem.common.core.TangemSdkError +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class DynamicAddressesDelegate( + private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, + private val isXpubDerivedUseCase: IsXpubDerivedUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase, + private val uiMessageSender: UiMessageSender, + private val userWalletId: UserWalletId, + private val network: Network, + private val coroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, + private val showBottomSheet: () -> Unit, + private val dismissBottomSheet: () -> Unit, + private val onDynamicAddressesEnabled: () -> Unit, +) { + + private val _bottomSheetConfig = MutableStateFlow( + DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = false, + onEnableClick = {}, + ), + ) + val bottomSheetConfig: StateFlow = _bottomSheetConfig.asStateFlow() + + fun onDynamicAddressesClick() { + coroutineScope.launch(dispatchers.main) { + val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) + if (hasConflicts) { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable( + onGotItClick = dismissBottomSheet, + ) + showBottomSheet() + return@launch + } + + val isCardScanRequired = !isXpubAlreadyDerived() + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = isCardScanRequired, + onEnableClick = ::onEnableClick, + ) + showBottomSheet() + } + } + + private fun onEnableClick() { + coroutineScope.launch(dispatchers.main) { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = false, + isLoading = true, + onEnableClick = {}, + ) + + val xpub = getExtendedPublicKeyUseCase(userWalletId, network).fold( + ifLeft = { error -> + if (isUserCancellation(error)) { + dismissBottomSheet() + } else { + TangemLogger.e("Failed to get XPUB: ${error.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onGotItClick = dismissBottomSheet, + ) + } + return@launch + }, + ifRight = { it }, + ) + + enableDynamicAddressesUseCase(userWalletId, network, xpub).fold( + ifLeft = { error -> + when (error) { + is EnableDynamicAddressesError.ConflictingCustomTokens -> { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable( + onGotItClick = dismissBottomSheet, + ) + } + is EnableDynamicAddressesError.ServiceError -> { + TangemLogger.e("Failed to enable DA: ${error.cause.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onGotItClick = dismissBottomSheet, + ) + } + } + }, + ifRight = { + dismissBottomSheet() + onDynamicAddressesEnabled() + uiMessageSender.send( + SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_enabled_toast_title)), + ) + }, + ) + } + } + + private suspend fun isXpubAlreadyDerived(): Boolean { + return isXpubDerivedUseCase(userWalletId, network) + } + + private fun isUserCancellation(error: Throwable): Boolean { + return error is TangemSdkError.UserCancelled || error.cause is TangemSdkError.UserCancelled + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index af6e80b86e..81e1b3a3d0 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -48,6 +48,8 @@ interface TokenDetailsClickIntents { fun onGenerateExtendedKey() + fun onDynamicAddressesClick() + fun onCopyAddress(): TextReference? fun onAssociateClick() @@ -125,6 +127,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { override fun onGenerateExtendedKey() { /* no op */ } + override fun onDynamicAddressesClick() { /* no op */ } + override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 4cd0017182..46d9a4eb87 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -2,13 +2,19 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable import arrow.core.getOrElse -import arrow.core.merge import arrow.core.right import com.tangem.utils.logging.TangemLogger import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.address.AddressType +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.receive.AddressModel @@ -16,9 +22,7 @@ import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped @@ -53,6 +57,7 @@ import com.tangem.domain.models.TokenReceiveNotification import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -154,7 +159,6 @@ internal class TokenDetailsModel @Inject constructor( private val appRouter: AppRouter, private val router: InnerTokenDetailsRouter, private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener, - private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val receiveAddressesFactory: ReceiveAddressesFactory, private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase, private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase, @@ -163,6 +167,11 @@ internal class TokenDetailsModel @Inject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase, private val signCloreMessageUseCase: SignCloreMessageUseCase, + private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, + private val isXpubSupportedUseCase: IsXpubSupportedUseCase, + private val isXpubDerivedUseCase: IsXpubDerivedUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ) : Model(), TokenDetailsClickIntents, ExpressTransactionsClickIntents, @@ -226,6 +235,27 @@ internal class TokenDetailsModel @Inject constructor( } // endregion + // region Dynamic Addresses + val dynamicAddressesDelegate by lazy(mode = LazyThreadSafetyMode.NONE) { + DynamicAddressesDelegate( + enableDynamicAddressesUseCase = enableDynamicAddressesUseCase, + isXpubDerivedUseCase = isXpubDerivedUseCase, + dynamicAddressesRepository = dynamicAddressesRepository, + getExtendedPublicKeyUseCase = getExtendedPublicKeyForCurrencyUseCase, + uiMessageSender = uiMessageSender, + userWalletId = userWalletId, + network = cryptoCurrency.network, + coroutineScope = modelScope, + dispatchers = dispatchers, + showBottomSheet = { + bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.DynamicAddresses) + }, + dismissBottomSheet = bottomSheetNavigation::dismiss, + onDynamicAddressesEnabled = ::onDynamicAddressesEnabled, + ) + } + // endregion Dynamic Addresses + private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) { tokenDetailsExpressStatusFactory.create( clickIntents = this, @@ -485,39 +515,45 @@ internal class TokenDetailsModel @Inject constructor( ).getOrElse { false } val isSupported = isXPUBSupported() + val isDynamicAddressesAvailable = isSupported && isDynamicAddressesAvailable() internalUiState.value = stateFactory.getStateWithUpdatedMenu( userWallet = userWallet, hasDerivations = hasDerivations, isSupported = isSupported, + isDynamicAddressesAvailable = isDynamicAddressesAvailable, ) } } + private fun isDynamicAddressesAvailable(): Boolean { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false + if (cryptoCurrency !is CryptoCurrency.Coin) return false + + val networkId = cryptoCurrency.network.rawId + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(networkId)) return false + + return isDefaultBaseDerivation(cryptoCurrency.network.derivationPath, networkId) + } + + private fun isDefaultBaseDerivation(derivationPath: Network.DerivationPath, networkId: String): Boolean { + val pathValue = derivationPath.value ?: return false + val nodes = runCatching { DerivationPath(pathValue).nodes }.getOrNull() ?: return false + if (nodes.size < BASE_DERIVATION_NODE_COUNT) return false + + val purposeNode = nodes.first() + val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false + if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false + + val changeNode = nodes[nodes.size - 2] + val indexNode = nodes.last() + + return changeNode.getIndex(includeHardened = false) == 0L && + indexNode.getIndex(includeHardened = false) == 0L + } + private suspend fun isXPUBSupported(): Boolean { - return getExtendedPublicKeyForCurrencyUseCase.isSupported( - userWalletId = userWalletId, - network = cryptoCurrency.network, - ) - .mapLeft { throwable -> - analyticsExceptionHandler.sendException( - event = ExceptionAnalyticsEvent( - exception = throwable, - params = mapOf( - "blockchainId" to cryptoCurrency.network.id.rawId.value, - "networkId" to cryptoCurrency.network.backendId, - ), - ), - ) - - TangemLogger.e( - "Unable to get wallet manager for user wallet $userWalletId and network ${cryptoCurrency.network}", - throwable, - ) - - false - } - .merge() + return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network) } private fun createSelectedAppCurrencyFlow(): StateFlow { @@ -657,6 +693,15 @@ internal class TokenDetailsModel @Inject constructor( openStaking() } + override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick() + + private fun onDynamicAddressesEnabled() { + updateTopBarMenu() + modelScope.launch(dispatchers.main) { + cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency) + } + } + override fun onGenerateExtendedKey() { modelScope.launch(dispatchers.main) { val extendedKey = getExtendedPublicKeyForCurrencyUseCase( @@ -1382,5 +1427,6 @@ internal class TokenDetailsModel @Inject constructor( private companion object { const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L + const val BASE_DERIVATION_NODE_COUNT = 5 } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt index 8ba904843b..071fa53e51 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt @@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route { @Serializable data object CloreMigration : TokenDetailsBottomSheetConfig() + + @Serializable + data object DynamicAddresses : TokenDetailsBottomSheetConfig() } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index 3739c109ba..bd9da5b438 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -167,12 +167,18 @@ internal class TokenDetailsStateFactory( userWallet: UserWallet, hasDerivations: Boolean, isSupported: Boolean, + isDynamicAddressesAvailable: Boolean = false, ): TokenDetailsState { return with(currentStateProvider()) { copy( topAppBarConfig = topAppBarConfig.copy( tokenDetailsAppBarMenuConfig = topAppBarConfig.tokenDetailsAppBarMenuConfig - ?.updateMenu(userWallet, hasDerivations, isSupported), + ?.updateMenu( + userWallet = userWallet, + hasDerivations = hasDerivations, + isSupported = isSupported, + isDynamicAddressesAvailable = isDynamicAddressesAvailable, + ), ), ) } @@ -206,6 +212,7 @@ internal class TokenDetailsStateFactory( userWallet: UserWallet, hasDerivations: Boolean, isSupported: Boolean, + isDynamicAddressesAvailable: Boolean, ): TokenDetailsAppBarMenuConfig? { if (userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() @@ -215,6 +222,13 @@ internal class TokenDetailsStateFactory( return copy( items = buildList { + if (isDynamicAddressesAvailable) { + TangemDropdownMenuItem( + title = resourceReference(R.string.dynamic_addresses), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = tokenDetailsClickIntents::onDynamicAddressesClick, + ).let(::add) + } if (isSupported && hasDerivations) { TangemDropdownMenuItem( title = resourceReference(R.string.token_details_generate_xpub), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt new file mode 100644 index 0000000000..6b476ee779 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.feature.tokendetails.presentation.tokendetails.model.DynamicAddressesDelegate +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheet + +internal class DynamicAddressesBottomSheetComponent( + private val dynamicAddressesDelegate: DynamicAddressesDelegate, + private val onDismiss: () -> Unit, +) : ComposableBottomSheetComponent { + + override fun dismiss() { + onDismiss() + } + + @Composable + override fun BottomSheet() { + val content by dynamicAddressesDelegate.bottomSheetConfig.collectAsStateWithLifecycle() + + val config = remember(content) { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = content, + ) + } + DynamicAddressesBottomSheet(config = config) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt new file mode 100644 index 0000000000..a12557f768 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.R as CoreR + +@Composable +internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + title = { + TangemModalBottomSheetTitle( + endIconRes = CoreR.drawable.ic_close_24, + onEndClick = config.onDismissRequest, + ) + }, + ) { content -> + when (content) { + is DynamicAddressesBottomSheetConfig.Enable -> DynamicAddressesEnableContent(content = content) + is DynamicAddressesBottomSheetConfig.Unavailable -> DynamicAddressesUnavailableContent(content = content) + is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> DynamicAddressesServiceUnavailableContent( + content = content, + ) + } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt new file mode 100644 index 0000000000..a228fb29b2 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt @@ -0,0 +1,22 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +@Immutable +internal sealed class DynamicAddressesBottomSheetConfig : TangemBottomSheetConfigContent { + + data class Enable( + val isCardScanRequired: Boolean, + val isLoading: Boolean = false, + val onEnableClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + data class Unavailable( + val onGotItClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + data class ServiceUnavailable( + val onGotItClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt new file mode 100644 index 0000000000..fb1eaa7605 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt @@ -0,0 +1,179 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.res.R +import com.tangem.core.ui.R as CoreR + +@Composable +internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetConfig.Enable) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_top), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size44), + tint = TangemTheme.colors.icon.accent, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses_enter_subtitle), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + FeatureItem( + iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_flash_24, + title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_title), + description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_description), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + + FeatureItem( + iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_check_24, + title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_title), + description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_description), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButton( + text = stringResourceSafe(id = R.string.dynamic_addresses_enter_main_button_title), + onClick = content.onEnableClick, + modifier = Modifier.fillMaxWidth(), + showProgress = content.isLoading, + enabled = !content.isLoading, + // TODO add card icon when isCardScanRequired + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +internal fun DynamicAddressesUnavailableContent(content: DynamicAddressesBottomSheetConfig.Unavailable) { + ErrorContent( + titleRes = R.string.dynamic_addresses_error_has_custom_token_title, + descriptionRes = R.string.dynamic_addresses_error_has_custom_token_description, + buttonTextRes = R.string.common_got_it, + onButtonClick = content.onGotItClick, + ) +} + +@Composable +internal fun DynamicAddressesServiceUnavailableContent(content: DynamicAddressesBottomSheetConfig.ServiceUnavailable) { + ErrorContent( + titleRes = R.string.dynamic_addresses_error_service_unavailable_title, + descriptionRes = R.string.dynamic_addresses_error_service_unavailable_description, + buttonTextRes = R.string.common_got_it, + onButtonClick = content.onGotItClick, + ) +} + +@Composable +private fun ErrorContent(titleRes: Int, descriptionRes: Int, buttonTextRes: Int, onButtonClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size44), + tint = TangemTheme.colors.icon.warning, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Text( + text = stringResourceSafe(id = titleRes), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + + Text( + text = stringResourceSafe(id = descriptionRes), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButton( + text = stringResourceSafe(id = buttonTextRes), + onClick = onButtonClick, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +private fun FeatureItem(iconRes: Int, title: String, description: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size24), + tint = TangemTheme.colors.icon.accent, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing4)) + Text( + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} \ No newline at end of file