From afbdca53fedfeba9fcc96611e0bcf58abe8ab153 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 16:11:23 +0500 Subject: [PATCH 1/2] Updated on 2026-08-14 --- .../feed/components/FeedEntryChildFactory.kt | 4 + .../tangem/features/foryou/ForYouComponent.kt | 1 + .../foryou/impl/DefaultForYouComponent.kt | 47 ++++++ .../impl/entity/ForYouBottomSheetConfig.kt | 12 ++ .../entity/ForYouEarnOpportunitiesType.kt | 31 ++++ .../features/foryou/impl/entity/ForYouUM.kt | 1 + .../features/foryou/impl/model/ForYouModel.kt | 135 ++++++++++++++++++ .../impl/model/converter/ForYouUtils.kt | 4 + .../ForYouEarnOpportunitiesConverter.kt | 16 +++ ...orYouEarnOpportunitiesNoTokensConverter.kt | 8 +- ...nOpportunitiesPotentialRewardsConverter.kt | 14 +- ...orYouEarnOpportunitiesTokenRowConverter.kt | 13 +- ...uEarnOpportunitiesTokensActiveConverter.kt | 8 +- ...ouEarnOpportunitiesTopTokenRowConverter.kt | 25 +++- .../foryou/impl/ui/ForYouEarnOpportunities.kt | 16 +++ .../ForYouEarnOpportunitiesPreviewData.kt | 3 + 16 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouBottomSheetConfig.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouEarnOpportunitiesType.kt 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 91e9077c31..48036e5d0a 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 @@ -174,6 +174,10 @@ internal class FeedEntryChildFactory @Inject constructor( token = TokenSummaryComponent.Token.Portfolio(currency), ) } + + override fun onAllEarnTokensClick() { + feedEntryClickIntents.onOpenEarnPage() + } }, ), ) diff --git a/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt index 3edc7a03c2..30822eed93 100644 --- a/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt +++ b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt @@ -13,6 +13,7 @@ interface ForYouComponent : ComposableModularBottomSheetContentComponent { interface ForYouModelCallbacks { fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) + fun onAllEarnTokensClick() } interface Factory : ComponentFactory diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt index a6323a1230..2c33e64590 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt @@ -12,12 +12,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.extensions.clickableSingle @@ -25,7 +31,10 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20 +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent import com.tangem.features.foryou.ForYouComponent +import com.tangem.features.foryou.impl.entity.ForYouBottomSheetConfig import com.tangem.features.foryou.impl.model.ForYouModel import com.tangem.features.foryou.impl.ui.ForYouContent import com.tangem.features.promobanners.api.PromoBannersBlockComponent @@ -37,6 +46,8 @@ internal class DefaultForYouComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted params: ForYouComponent.Params, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, + private val manageFundsComponentFactory: ManageFundsComponent.Factory, ) : AppComponentContext by context, ForYouComponent { private val model: ForYouModel = getOrCreateModel(params = params) @@ -51,6 +62,18 @@ internal class DefaultForYouComponent @AssistedInject constructor( ) } + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + childFactory = { config, componentContext -> + when (config) { + ForYouBottomSheetConfig.AddToPortfolio -> portfolioSelectorChild(componentContext) + is ForYouBottomSheetConfig.ManageFunds -> manageFundsChild(config, componentContext) + } + }, + ) + @Composable override fun Title(bottomSheetState: State) { TangemTopBar( @@ -82,6 +105,7 @@ internal class DefaultForYouComponent @AssistedInject constructor( modifier: Modifier, ) { val uiState by model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() ForYouContent( forYouUM = uiState, @@ -90,8 +114,31 @@ internal class DefaultForYouComponent @AssistedInject constructor( contentPadding = contentPadding, modifier = modifier, ) + + bottomSheet.child?.instance?.BottomSheet() } + private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent = + addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = checkNotNull(model.addToPortfolioManager) { + "addToPortfolioManager must be set before activating AddToPortfolio slot" + }, + ), + ) + + private fun manageFundsChild( + config: ForYouBottomSheetConfig.ManageFunds, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = manageFundsComponentFactory.create( + context = childByContext(componentContext), + params = ManageFundsComponent.Params( + launchMode = ManageFundsComponent.LaunchMode.FilteredByRawId(config.rawCurrencyId), + onDismiss = { model.bottomSheetNavigation.dismiss() }, + ), + ) + @AssistedFactory interface Factory : ForYouComponent.Factory { override fun create(context: AppComponentContext, params: ForYouComponent.Params): DefaultForYouComponent diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouBottomSheetConfig.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouBottomSheetConfig.kt new file mode 100644 index 0000000000..1b798d9b26 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouBottomSheetConfig.kt @@ -0,0 +1,12 @@ +package com.tangem.features.foryou.impl.entity + +import com.tangem.domain.models.currency.CryptoCurrency + +internal sealed interface ForYouBottomSheetConfig { + + data object AddToPortfolio : ForYouBottomSheetConfig + + data class ManageFunds( + val rawCurrencyId: CryptoCurrency.RawID, + ) : ForYouBottomSheetConfig +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouEarnOpportunitiesType.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouEarnOpportunitiesType.kt new file mode 100644 index 0000000000..cea8dd473d --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouEarnOpportunitiesType.kt @@ -0,0 +1,31 @@ +package com.tangem.features.foryou.impl.entity + +import com.tangem.domain.staking.model.StakingIntegrationID + +/** + * The earn product behind an earn-opportunities row. Resolved once per token when the row is built + * and carried through the click callback, so the model knows which earn screen to open + * ([com.tangem.common.routing.AppRoute.YieldSupplyEntry] or [com.tangem.common.routing.AppRoute.Staking]) + * without re-resolving availability. + */ +internal sealed interface ForYouEarnOpportunitiesType { + + /** + * Yield supply (yield module) opportunity. + * + * @property apy rate in percent as received from the backend (e.g. "5.5" = 5.5%), passed as-is + * to the yield-supply entry route + */ + data class YieldSupply( + val apy: String, + ) : ForYouEarnOpportunitiesType + + /** + * Staking opportunity. + * + * @property integrationID staking integration to open the staking screen with + */ + data class Staking( + val integrationID: StakingIntegrationID, + ) : ForYouEarnOpportunitiesType +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt index 2291181eaf..de0075f610 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/entity/ForYouUM.kt @@ -62,6 +62,7 @@ internal sealed interface EarnOpportunitiesUM { @param:StringRes val subtitleRes: Int, val potentialReward: TextReference?, val potentialRewardType: TextReference?, + val onAllEarnTokensClick: () -> Unit, ) : EarnOpportunitiesUM } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt index 561d48003a..9c920731d9 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt @@ -4,6 +4,12 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse import arrow.core.left import arrow.core.right +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -24,6 +30,7 @@ import com.tangem.domain.models.earn.EarnTopToken import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.foryou.ForYouComponent import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.* @@ -38,7 +45,11 @@ import com.tangem.utils.coroutines.combine6 import com.tangem.utils.transformer.update import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.* +import kotlinx.coroutines.job import javax.inject.Inject @Stable @@ -49,12 +60,14 @@ internal class ForYouModel @Inject constructor( userWalletsListRepository: UserWalletsListRepository, multiAccountStatusListSupplier: MultiAccountStatusListSupplier, yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase, + private val router: AppRouter, override val dispatchers: CoroutineDispatcherProvider, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase, private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val earnErrorResolver: EarnErrorResolver, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, ) : Model() { private val params = paramsContainer.require() @@ -63,6 +76,13 @@ internal class ForYouModel @Inject constructor( private val expandedEarnOpportunitiesAssetIds = MutableStateFlow>(value = emptySet()) private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + var addToPortfolioManager: AddToPortfolioManager? = null + private set + + private var addToPortfolioManagerScope: CoroutineScope? = null + val uiState: StateFlow field = MutableStateFlow( ForYouUM( @@ -144,6 +164,8 @@ internal class ForYouModel @Inject constructor( topEarnTokens = topEarnTokens, expandedAssetIds = expandedEarnOpportunities, expandClick = ::onExpandEarnOpportunitiesClick, + onTokenClick = ::onEarnOpportunitiesTokenClick, + onAllEarnTokensClick = params.callbacks::onAllEarnTokensClick, ).convert(accountStatusList) uiState.update( @@ -207,6 +229,46 @@ internal class ForYouModel @Inject constructor( params.callbacks.onTokenClick(walletId, currency) } + private fun onEarnOpportunitiesTokenClick( + selectedWalletId: UserWalletId?, + currency: CryptoCurrency, + type: ForYouEarnOpportunitiesType, + ) { + when { + selectedWalletId != null -> openEarnScreen( + userWalletId = selectedWalletId, + currency = currency, + type = type, + ) + else -> { + // TODO For you make logic if not added add token, otherwise manage funds + // val token = RawMarketToken( + // id = currency.id.rawCurrencyId ?: return, + // name = currency.name, + // symbol = currency.symbol, + // ) + // val network = TokenMarketInfo.Network( + // networkId = currency.network.rawId, + // isExchangeable = false, + // contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, + // decimalCount = currency.decimals, + // ) + // val manager = createAddToPortfolioManager().apply { + // setTokenParams(token) + // setTokenNetworks(listOf(network)) + // } + // addToPortfolioManager = manager + // Drop the slot through null so the same-source repeat click still recreates the child. + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate( + ForYouBottomSheetConfig.ManageFunds( + currency.id.rawCurrencyId ?: return, + ), + ) + } + } + } + private fun onExpandPortfolioReviewClick(assetId: String) { expandedPortfolioReviewAssetIds.update { ids -> if (assetId in ids) ids - assetId else ids + assetId @@ -228,4 +290,77 @@ internal class ForYouModel @Inject constructor( ) } } + + // TODO For you make logic if not added add token, otherwise manage funds + @Suppress("UnusedPrivateMember") + private fun createAddToPortfolioManager(): AddToPortfolioManager { + addToPortfolioManagerScope?.cancel() + val managerScope = CoroutineScope( + modelScope.coroutineContext + SupervisorJob(modelScope.coroutineContext.job), + ) + addToPortfolioManagerScope = managerScope + + val manager = addToPortfolioManagerFactory.create( + scope = managerScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams( + source = AnalyticsParam.ScreensSources.Markets.value, + ), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.ViaUserPortfolio) + } + + manager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(managerScope) + manager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach { result -> + router.push( + AppRoute.CurrencyDetails( + userWalletId = result.wallet.walletId, + currency = result.addedCurrency.currency, + ), + ) + } + .launchIn(managerScope) + manager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach { result -> + router.push( + AppRoute.CurrencyDetails( + userWalletId = result.wallet.walletId, + currency = result.addedCurrency.currency, + ), + ) + } + .launchIn(managerScope) + + return manager + } + + private fun openEarnScreen( + userWalletId: UserWalletId, + currency: CryptoCurrency, + type: ForYouEarnOpportunitiesType, + ) { + router.push( + when (type) { + is ForYouEarnOpportunitiesType.Staking -> { + AppRoute.Staking( + userWalletId = userWalletId, + cryptoCurrency = currency, + integrationId = type.integrationID, + ) + } + is ForYouEarnOpportunitiesType.YieldSupply -> { + AppRoute.YieldSupplyEntry( + userWalletId = userWalletId, + cryptoCurrency = currency, + apy = type.apy, + ) + } + }, + ) + } } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt index d667614973..a5afe20680 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouUtils.kt @@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.utils.extensions.isZero import java.math.BigDecimal import java.math.RoundingMode @@ -62,11 +63,14 @@ internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM( * @property isActive whether the user already earns on the token (active yield supply or stake) * @property apy rate as a fraction (0.05 = 5%) * @property potentialRewards projected yearly reward in fiat (`fiatAmount * apy`), `null` when unknown + * @property type which earn product the rate belongs to; passed to the click callback so the model + * can open the matching earn screen */ internal data class EarnApyInfo( val isActive: Boolean, val apy: BigDecimal?, val potentialRewards: BigDecimal?, + val type: ForYouEarnOpportunitiesType, ) /** diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt index 8f4a348934..ebd873738c 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverter.kt @@ -8,12 +8,15 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.earn.EarnTopToken import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.common.RewardInfo import com.tangem.domain.staking.model.common.RewardType import com.tangem.domain.staking.model.optionOrNull import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.model.converter.EarnApyInfo import com.tangem.features.foryou.impl.model.converter.EarnOpportunities import com.tangem.features.foryou.impl.model.converter.PERCENT_BASE @@ -45,6 +48,8 @@ internal class ForYouEarnOpportunitiesConverter( private val yieldSupplyAvailability: Map, private val yieldStakingAvailability: Map, private val topEarnTokens: EarnTopToken?, + private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit, + private val onAllEarnTokensClick: () -> Unit, ) : Converter { override fun convert(value: AccountStatusList?): EarnOpportunitiesUM { @@ -86,19 +91,26 @@ internal class ForYouEarnOpportunitiesConverter( data.isEmpty() -> { ForYouEarnOpportunitiesNoTokensConverter( topEarnTokens = topEarnTokens, + onTokenClick = onTokenClick, + onAllEarnTokensClick = onAllEarnTokensClick, ).convert(data) } data.all { earn -> earn.earnCurrencies.all { entry -> entry.value.isActive } } -> { ForYouEarnOpportunitiesTokensActiveConverter( topEarnTokens = topEarnTokens, + onTokenClick = onTokenClick, + onAllEarnTokensClick = onAllEarnTokensClick, ).convert(data) } else -> { ForYouEarnOpportunitiesPotentialRewardsConverter( appCurrency = appCurrency, + userWalletId = value?.userWalletId, isAccountsModeEnabled = isAccountsModeEnabled, expandedAssetIds = expandedAssetIds, expandClick = expandClick, + onTokenClick = onTokenClick, + onAllEarnTokensClick = onAllEarnTokensClick, ).convert(data) } } @@ -129,6 +141,7 @@ internal class ForYouEarnOpportunitiesConverter( isActive = isActive, potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(apy), apy = apy, + type = ForYouEarnOpportunitiesType.YieldSupply(yieldSupplyApy.toPlainString()), ) } } @@ -143,6 +156,7 @@ internal class ForYouEarnOpportunitiesConverter( isActive = stakingInfo.isActive, apy = stakingInfo.rate, potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(stakingInfo.rate), + type = ForYouEarnOpportunitiesType.Staking(integrationID = stakingInfo.integrationId), ) } } @@ -205,6 +219,7 @@ internal class ForYouEarnOpportunitiesConverter( rate = rateInfo.rate, isActive = isActive, rewardType = rateInfo.type, + integrationId = option.integrationId, ) } @@ -212,5 +227,6 @@ internal class ForYouEarnOpportunitiesConverter( val rate: BigDecimal, val isActive: Boolean, val rewardType: RewardType, + val integrationId: StakingIntegrationID, ) } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt index 2f363e16d9..06f1d35a1f 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverter.kt @@ -5,7 +5,10 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.utils.parseBigDecimalOrNull +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnTopToken +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM import com.tangem.features.foryou.impl.model.converter.EarnOpportunities import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT @@ -20,6 +23,8 @@ import kotlinx.collections.immutable.toPersistentList */ internal class ForYouEarnOpportunitiesNoTokensConverter( private val topEarnTokens: EarnTopToken?, + private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit, + private val onAllEarnTokensClick: () -> Unit, ) : Converter, EarnOpportunitiesUM> { override fun convert(value: List): EarnOpportunitiesUM { @@ -29,7 +34,7 @@ internal class ForYouEarnOpportunitiesNoTokensConverter( val topEarnToken = topEarnTokenList?.firstOrNull()?.earnToken val topEarnApy = topEarnToken?.apy?.parseBigDecimalOrNull() - val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter() + val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter(onTokenClick = onTokenClick) return EarnOpportunitiesUM.Content( tokenList = topEarnTokenList @@ -39,6 +44,7 @@ internal class ForYouEarnOpportunitiesNoTokensConverter( subtitleRes = R.string.for_you_earn_opportunities_no_available_tokens, potentialReward = stringReference(topEarnApy.format { percent() }), potentialRewardType = topEarnToken?.rewardType?.name?.let(::stringReference), + onAllEarnTokensClick = onAllEarnTokensClick, ) } } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt index b93413e988..2af52de079 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverter.kt @@ -11,7 +11,10 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.model.converter.EarnOpportunities import com.tangem.utils.StringsSigns @@ -29,14 +32,22 @@ import java.math.BigDecimal * [ForYouEarnOpportunitiesTokenRowConverter], expansion keyed by account id); with it off, the * tokens are rendered as flat non-expandable rows. */ +@Suppress("LongParameterList") internal class ForYouEarnOpportunitiesPotentialRewardsConverter( private val appCurrency: AppCurrency, + private val userWalletId: UserWalletId?, private val isAccountsModeEnabled: Boolean, private val expandedAssetIds: Set, private val expandClick: (assetId: String) -> Unit, + private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit, + private val onAllEarnTokensClick: () -> Unit, ) : Converter, EarnOpportunitiesUM> { - private val rowConverter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency) + private val rowConverter = ForYouEarnOpportunitiesTokenRowConverter( + appCurrency = appCurrency, + userWalletId = userWalletId, + onTokenClick = onTokenClick, + ) override fun convert(value: List): EarnOpportunitiesUM { val totalPotentialReward = value.sumOf { it.accountPotentialReward } @@ -82,6 +93,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverter( subtitleRes = R.string.for_you_earn_opportunities_tokens_rewards, potentialReward = totalPotentialRewardText, potentialRewardType = null, + onAllEarnTokensClick = onAllEarnTokensClick, ) } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt index cb60890b9d..55f47be1bd 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverter.kt @@ -14,7 +14,10 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.model.converter.EarnApyInfo import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter @@ -31,6 +34,8 @@ import java.math.BigDecimal */ internal class ForYouEarnOpportunitiesTokenRowConverter( private val appCurrency: AppCurrency, + private val userWalletId: UserWalletId?, + private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit, ) : Converter, TangemTokenRowUM> { private val iconConverter = CryptoCurrencyToIconStateConverter() @@ -55,7 +60,13 @@ internal class ForYouEarnOpportunitiesTokenRowConverter( ), topEndContentUM = toRowTopEnd(cryptoCurrencyStatus, possibleEarnAmount), bottomEndContentUM = toRowBottomEnd(cryptoCurrencyStatus, earnApyInfo.apy.orZero()), - onItemClick = null, + onItemClick = { + onTokenClick( + userWalletId, + cryptoCurrencyStatus.currency, + earnApyInfo.type, + ) + }, onItemLongClick = null, ) } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt index 2c0aaadc55..bd4b3e743d 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverter.kt @@ -1,8 +1,11 @@ package com.tangem.features.foryou.impl.model.converter.earnOpportunities import com.tangem.common.ui.R +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnTopToken +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.model.converter.EarnOpportunities import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT import com.tangem.features.foryou.impl.model.converter.forYouEarnAssetKey @@ -20,6 +23,8 @@ import kotlinx.collections.immutable.toPersistentList */ internal class ForYouEarnOpportunitiesTokensActiveConverter( private val topEarnTokens: EarnTopToken?, + private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit, + private val onAllEarnTokensClick: () -> Unit, ) : Converter, EarnOpportunitiesUM> { override fun convert(value: List): EarnOpportunitiesUM { @@ -28,7 +33,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverter( .map { status -> status.currency.forYouEarnAssetKey() } .toSet() - val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter() + val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter(onTokenClick) return EarnOpportunitiesUM.Content( tokenList = topEarnTokens?.getOrNull() @@ -40,6 +45,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverter( subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active, potentialReward = null, potentialRewardType = null, + onAllEarnTokensClick = onAllEarnTokensClick, ) } } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt index c8db01a057..47359ddc9c 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverter.kt @@ -10,8 +10,12 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnTokenWithCurrency import com.tangem.domain.models.earn.EarnType +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf @@ -23,7 +27,9 @@ import java.math.BigDecimal * converters that surface suggestions ([ForYouEarnOpportunitiesNoTokensConverter], * [ForYouEarnOpportunitiesTokensActiveConverter]). */ -internal class ForYouEarnOpportunitiesTopTokenRowConverter : Converter { +internal class ForYouEarnOpportunitiesTopTokenRowConverter( + private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit, +) : Converter { private val iconConverter = CryptoCurrencyToIconStateConverter() @@ -57,7 +63,22 @@ internal class ForYouEarnOpportunitiesTopTokenRowConverter : Converter { + val integrationID = StakingIntegrationID.create(currencyId = value.cryptoCurrency.id) + ?: return@Content + + ForYouEarnOpportunitiesType.Staking(integrationID = integrationID) + } + EarnType.YIELD -> ForYouEarnOpportunitiesType.YieldSupply(apy = earnToken.apy) + } + onTokenClick( + null, + value.cryptoCurrency, + type, + ) + }, onItemLongClick = { _, _ -> }, ), tokenList = persistentListOf(), diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt index cb513e9ac9..0edc0676d6 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/ForYouEarnOpportunities.kt @@ -3,6 +3,7 @@ package com.tangem.features.foryou.impl.ui import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent @@ -19,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.shimmers.TangemShimmer import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme @@ -84,6 +86,20 @@ internal fun ForYouEarnOpportunities(earnOpportunitiesUM: EarnOpportunitiesUM, m tokenList = earnOpportunitiesUM.tokenList, modifier = Modifier.padding(top = 8.dp), ) + + if (earnOpportunitiesUM is EarnOpportunitiesUM.Content) { + TangemButton( + text = stringReference("Explore all tokens"), // todo FOR YOU lokalize + variant = TangemButton.Variant.Secondary, + size = TangemButton.Size.X9, + isEnabled = true, + contentDescription = "Explore all tokens", // todo FOR YOU lokalize + onClick = earnOpportunitiesUM.onAllEarnTokensClick, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } } } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt index 400a57c04b..f8f20dc8f4 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/preview/ForYouEarnOpportunitiesPreviewData.kt @@ -36,6 +36,7 @@ internal object ForYouEarnOpportunitiesPreviewData { bottomEnd = "6,2%", ), ), + onAllEarnTokensClick = {}, ) /** No earnable tokens in the portfolio: top earn tokens teaser. */ @@ -52,6 +53,7 @@ internal object ForYouEarnOpportunitiesPreviewData { bottomEnd = "Staking", ), ), + onAllEarnTokensClick = {}, ) /** Every earnable token is already earning: plain subtitle, no reward badge, no rows. */ @@ -60,6 +62,7 @@ internal object ForYouEarnOpportunitiesPreviewData { potentialReward = null, potentialRewardType = null, tokenList = persistentListOf(), + onAllEarnTokensClick = {}, ) val loading = EarnOpportunitiesUM.Loading( From edee792251690438a27b9bf5af52b66ed5bb30bf Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 16:11:38 +0500 Subject: [PATCH 2/2] Updated on 2026-08-14 --- .../foryou/impl/model/ForYouModelTest.kt | 102 ++++++++++++++++-- .../EarnOpportunitiesTestFactory.kt | 5 +- .../ForYouEarnOpportunitiesConverterTest.kt | 62 ++++++++++- ...uEarnOpportunitiesNoTokensConverterTest.kt | 6 ++ ...ortunitiesPotentialRewardsConverterTest.kt | 34 ++++++ ...uEarnOpportunitiesTokenRowConverterTest.kt | 35 +++++- ...nOpportunitiesTokensActiveConverterTest.kt | 14 ++- ...rnOpportunitiesTopTokenRowConverterTest.kt | 24 ++++- .../SetPortfolioReviewTransformerTest.kt | 1 + 9 files changed, 265 insertions(+), 18 deletions(-) diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt index 2dd1deb193..b021e7f4b2 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt @@ -2,6 +2,8 @@ package com.tangem.features.foryou.impl.model import arrow.core.right import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.ui.ds.row.token.TangemTokenRowUM @@ -21,15 +23,16 @@ import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.earn.EarnError -import com.tangem.domain.models.earn.EarnRewardType -import com.tangem.domain.models.earn.EarnToken -import com.tangem.domain.models.earn.EarnTokenWithCurrency -import com.tangem.domain.models.earn.EarnType +import com.tangem.domain.models.currency.yieldSupplyKey +import com.tangem.domain.models.earn.* import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.foryou.ForYouComponent import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM @@ -41,12 +44,7 @@ import com.tangem.pagination.BatchListState import com.tangem.pagination.PaginationStatus import com.tangem.test.mock.MockAccounts import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.CapturingSlot -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -72,6 +70,8 @@ internal class ForYouModelTest { private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase = mockk() private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() private val earnErrorResolver: EarnErrorResolver = mockk() + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory = mockk() + private val router: AppRouter = mockk(relaxUnitFun = true) private var model: ForYouModel? = null @@ -286,9 +286,67 @@ internal class ForYouModelTest { } } + @Nested + inner class EarnNavigation { + + @Test + fun `GIVEN held yield-eligible token WHEN earn row clicked THEN yield-supply entry route is pushed`() = + runTest { + // Arrange — the token's backend rate is 5.5% + val token = createYieldToken() + every { yieldSupplyApyFlowUseCase() } returns flowOf(mapOf(token.yieldSupplyKey() to BigDecimal("5.5"))) + stubSelectedWallet(currencies = listOf(createStatus(token, loadedValue(BigDecimal("100"))))) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.clickFirstEarnRow() + + // Assert — the raw percent string travels into the route + val expected = AppRoute.YieldSupplyEntry( + userWalletId = WALLET_ID, + cryptoCurrency = token, + apy = "5.5", + ) + verify { router.push(route = expected, onComplete = any()) } + } + + @Test + fun `GIVEN held stakeable token WHEN earn row clicked THEN staking route is pushed`() = runTest { + // Arrange + val currency = createCoin(rawCurrencyId = "eth", symbol = "ETH") + val option: StakingOption.P2PEthPool = mockk { + every { apy } returns BigDecimal("0.05") + every { integrationId } returns StakingIntegrationID.P2PEthPool + } + coEvery { stakingAvailabilityListUseCase.invokeSync(any(), any()) } returns + mapOf(currency to StakingAvailability.Available(option)) + stubSelectedWallet(currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100"))))) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.clickFirstEarnRow() + + // Assert + val expected = AppRoute.Staking( + userWalletId = WALLET_ID, + cryptoCurrency = currency, + integrationId = StakingIntegrationID.P2PEthPool, + ) + verify { router.push(route = expected, onComplete = any()) } + } + } + private fun PortfolioReviewUM.Content.assetRow(): TangemTokenRowUM.Content = tokenList.single().tokenRowUM as TangemTokenRowUM.Content + private fun ForYouModel.clickFirstEarnRow() { + val earn = uiState.value.earnOpportunities as EarnOpportunitiesUM.Content + val row = earn.tokenList.first().tokenRowUM as TangemTokenRowUM.Content + row.onItemClick?.invoke() + } + /** Wires the repository + supplier so the model derives Content from a single selected wallet. */ private fun stubSelectedWallet( currencies: List, @@ -343,18 +401,21 @@ internal class ForYouModelTest { ForYouComponent.Params( callbacks = object : ForYouComponent.ForYouModelCallbacks { override fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) = Unit + override fun onAllEarnTokensClick() = Unit }, ), ), userWalletsListRepository = userWalletsListRepository, multiAccountStatusListSupplier = multiAccountStatusListSupplier, yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase, + router = router, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, getEarnTokensBatchFlowUseCase = getEarnTokensBatchFlowUseCase, stakingAvailabilityListUseCase = stakingAvailabilityListUseCase, isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, earnErrorResolver = earnErrorResolver, + addToPortfolioManagerFactory = addToPortfolioManagerFactory, ).also { model = it } } @@ -424,6 +485,25 @@ internal class ForYouModelTest { } } + /** A token whose `yieldSupplyKey()` resolves to `"ethereum_0xabc"`. */ + private fun createYieldToken(): CryptoCurrency.Token { + val network = createNetwork(networkRawId = "ethereum") + val currencyId: CryptoCurrency.ID = mockk { + every { value } returns "token-usdc" + every { rawCurrencyId } returns CryptoCurrency.RawID("usd-coin") + } + return mockk { + every { id } returns currencyId + every { symbol } returns "USDC" + every { name } returns "USD Coin" + every { this@mockk.network } returns network + every { decimals } returns 6 + every { isCustom } returns false + every { iconUrl } returns null + every { contractAddress } returns "0xabc" + } + } + private fun createNetwork(networkRawId: String): Network { val networkId: Network.ID = mockk { every { rawId } returns Network.RawID(networkRawId) diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt index 9bbc02f746..d765af9a28 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/EarnOpportunitiesTestFactory.kt @@ -13,6 +13,7 @@ import com.tangem.domain.models.earn.EarnType import com.tangem.domain.models.network.Network import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.model.converter.EarnApyInfo import com.tangem.features.foryou.impl.model.converter.EarnOpportunities import com.tangem.test.mock.MockAccounts @@ -180,7 +181,8 @@ internal fun createEarnApyInfo( isActive: Boolean = true, apy: BigDecimal? = BigDecimal("0.05"), potentialRewards: BigDecimal? = null, -): EarnApyInfo = EarnApyInfo(isActive = isActive, apy = apy, potentialRewards = potentialRewards) + type: ForYouEarnOpportunitiesType = ForYouEarnOpportunitiesType.YieldSupply(apy = "5.5"), +): EarnApyInfo = EarnApyInfo(isActive = isActive, apy = apy, potentialRewards = potentialRewards, type = type) internal fun createPortfolioStatus( currencies: List, @@ -192,4 +194,5 @@ internal fun createPortfolioStatus( internal fun createAccountStatusList(vararg statuses: AccountStatus): AccountStatusList = mockk { every { accountStatuses } returns statuses.toList() + every { userWalletId } returns MockAccounts.userWalletId } \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt index 25a6c7a14d..264b3e8276 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesConverterTest.kt @@ -15,12 +15,14 @@ import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.earn.EarnTopToken import com.tangem.domain.models.staking.BalanceItem import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.common.RewardInfo import com.tangem.domain.staking.model.common.RewardType import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM import com.tangem.test.mock.MockAccounts import io.mockk.every @@ -290,6 +292,55 @@ internal class ForYouEarnOpportunitiesConverterTest { } } + @Nested + inner class TypeResolution { + + @Test + fun `GIVEN yield-eligible token row clicked WHEN convert THEN yield type carries the raw percent apy`() { + // Arrange — the backend rate (10.00%) must reach the click callback unscaled + val token = createEarnTokenCurrency() + val status = createStatus(token, createEarnStatusValue(fiatAmount = BigDecimal("100"))) + var clickedType: ForYouEarnOpportunitiesType? = null + val converter = createConverter( + yieldSupplyAvailability = mapOf(token.yieldSupplyKey() to BigDecimal("10.00")), + onTokenClick = { _, _, type -> clickedType = type }, + ) + + // Act + val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status)))) + result.clickFirstRow() + + // Assert + assertThat(clickedType).isEqualTo(ForYouEarnOpportunitiesType.YieldSupply(apy = "10.00")) + } + + @Test + fun `GIVEN staking-eligible token row clicked WHEN convert THEN staking type carries the integration id`() { + // Arrange + val currency = createEarnCurrency() + val status = createStatus(currency, createEarnStatusValue(fiatAmount = BigDecimal("100"))) + val availability = stakingAvailable(apy = BigDecimal("0.05")) + var clickedType: ForYouEarnOpportunitiesType? = null + val converter = createConverter( + yieldStakingAvailability = mapOf(currency to availability), + onTokenClick = { _, _, type -> clickedType = type }, + ) + + // Act + val result = converter.convert(createAccountStatusList(createPortfolioStatus(listOf(status)))) + result.clickFirstRow() + + // Assert — the id comes from the resolved staking option + val option = (availability as StakingAvailability.Available).option + assertThat(clickedType).isEqualTo(ForYouEarnOpportunitiesType.Staking(integrationID = option.integrationId)) + } + + private fun EarnOpportunitiesUM.clickFirstRow() { + val row = tokenList.first().tokenRowUM as TangemTokenRowUM.Content + row.onItemClick?.invoke() + } + } + @Nested inner class AccountOrdering { @@ -328,6 +379,7 @@ internal class ForYouEarnOpportunitiesConverterTest { yieldStakingAvailability: Map = emptyMap(), topEarnTokens: EarnTopToken? = null, isAccountsModeEnabled: Boolean = false, + onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit = { _, _, _ -> }, ) = ForYouEarnOpportunitiesConverter( appCurrency = appCurrency, isAccountsModeEnabled = isAccountsModeEnabled, @@ -336,19 +388,23 @@ internal class ForYouEarnOpportunitiesConverterTest { yieldSupplyAvailability = yieldSupplyAvailability, yieldStakingAvailability = yieldStakingAvailability, topEarnTokens = topEarnTokens, + onTokenClick = onTokenClick, + onAllEarnTokensClick = {}, ) + // Real StakingIntegrationID values are used below: mocking the sealed interface makes mockk try to + // retransform its enum implementations, which the JVM rejects ("cannot change the class modifiers"). private fun stakingOption(apy: BigDecimal): StakingOption.P2PEthPool = mockk { every { this@mockk.apy } returns apy + every { integrationId } returns StakingIntegrationID.P2PEthPool } private fun stakingAvailable(apy: BigDecimal): StakingAvailability = StakingAvailability.Available(option = stakingOption(apy)) private fun stakeKitAvailable(vararg validatorList: Yield.Validator): StakingAvailability { - // A real StakeKit option with a real integration id: stubbing `integrationId` on a mock would - // make mockk instrument StakingIntegrationID.StakeKit, whose implementations are enums that the - // JVM refuses to retransform ("cannot change the class modifiers"). + // A real StakeKit option: stubbing `integrationId` on a mock would make mockk instrument + // StakingIntegrationID.StakeKit, hitting the same enum-retransformation limitation. val yieldModel: Yield = mockk { every { validators } returns validatorList.toList() every { apy } returns BigDecimal("0.10") diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt index b34fbcc926..bdcfafadfd 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesNoTokensConverterTest.kt @@ -20,6 +20,8 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest { topEarnTokens = List(7) { index -> createTopEarnToken(tokenId = "token-$index", networkRawId = "NET") }.right(), + onTokenClick = { _, _, _ -> }, + onAllEarnTokensClick = {}, ) // Act @@ -39,6 +41,8 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest { createTopEarnToken(apy = "7.25", rewardType = EarnRewardType.APR), createTopEarnToken(tokenId = "solana", apy = "99.9", rewardType = EarnRewardType.APY), ).right(), + onTokenClick = { _, _, _ -> }, + onAllEarnTokensClick = {}, ) // Act @@ -56,6 +60,8 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest { // Arrange val converter = ForYouEarnOpportunitiesNoTokensConverter( topEarnTokens = null, + onTokenClick = { _, _, _ -> }, + onAllEarnTokensClick = {}, ) // Act diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt index 32d1e4ed99..6d58b8df40 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesPotentialRewardsConverterTest.kt @@ -8,6 +8,9 @@ import com.tangem.core.ui.extensions.wrappedList 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.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM import com.tangem.test.mock.MockAccounts import org.junit.jupiter.api.Test @@ -108,6 +111,32 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { assertThat(clickedAssetId).isEqualTo(account.accountId.value) } + @Test + fun `GIVEN token row clicked WHEN convert THEN token callback receives wallet currency and earn type`() { + // Arrange + val currency = createEarnCurrency() + val earnType = ForYouEarnOpportunitiesType.YieldSupply(apy = "7.5") + val earnData = createEarnOpportunities( + earnCurrencies = mapOf( + createStatus(currency, createRowLoadedValue()) to createEarnApyInfo(isActive = false, type = earnType), + ), + ) + val walletId = UserWalletId("01") + var clicked: Triple? = null + val converter = createConverter( + isAccountsModeEnabled = false, + userWalletId = walletId, + onTokenClick = { id, clickedCurrency, type -> clicked = Triple(id, clickedCurrency, type) }, + ) + + // Act + val result = converter.convert(listOf(earnData)) as EarnOpportunitiesUM.Content + (result.tokenList.single().tokenRowUM as TangemTokenRowUM.Content).onItemClick?.invoke() + + // Assert + assertThat(clicked).isEqualTo(Triple(walletId, currency, earnType)) + } + @Test fun `GIVEN several accounts WHEN convert THEN header reward is the sum across accounts`() { // Arrange @@ -137,10 +166,15 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest { isAccountsModeEnabled: Boolean, expandedAssetIds: Set = emptySet(), expandClick: (String) -> Unit = {}, + userWalletId: UserWalletId? = UserWalletId("01"), + onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit = { _, _, _ -> }, ) = ForYouEarnOpportunitiesPotentialRewardsConverter( appCurrency = appCurrency, + userWalletId = userWalletId, isAccountsModeEnabled = isAccountsModeEnabled, expandedAssetIds = expandedAssetIds, expandClick = expandClick, + onTokenClick = onTokenClick, + onAllEarnTokensClick = {}, ) } \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt index 2c7840246c..4bd1b21747 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokenRowConverterTest.kt @@ -13,7 +13,10 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import com.tangem.utils.StringsSigns import io.mockk.every import io.mockk.mockk @@ -23,7 +26,16 @@ import java.math.BigDecimal internal class ForYouEarnOpportunitiesTokenRowConverterTest { private val appCurrency: AppCurrency = AppCurrency.Default - private val converter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency) + private val converter = createConverter() + + private fun createConverter( + userWalletId: UserWalletId? = UserWalletId("01"), + onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit = { _, _, _ -> }, + ) = ForYouEarnOpportunitiesTokenRowConverter( + appCurrency = appCurrency, + userWalletId = userWalletId, + onTokenClick = onTokenClick, + ) @Test fun `GIVEN loading status WHEN convert THEN row is Loading with currency id`() { @@ -95,6 +107,27 @@ internal class ForYouEarnOpportunitiesTokenRowConverterTest { assertThat(bottomEnd.startIcons).hasSize(1) } + @Test + fun `GIVEN row clicked WHEN convert THEN callback receives wallet currency and resolved earn type`() { + // Arrange + val currency = createEarnCurrency() + val status = createStatus(currency, createRowLoadedValue()) + val earnType = ForYouEarnOpportunitiesType.YieldSupply(apy = "5.5") + val walletId = UserWalletId("01") + var clicked: Triple? = null + val converter = createConverter( + userWalletId = walletId, + onTokenClick = { id, clickedCurrency, type -> clicked = Triple(id, clickedCurrency, type) }, + ) + + // Act + val result = converter.convert(status to createEarnApyInfo(type = earnType)) as TangemTokenRowUM.Content + result.onItemClick?.invoke() + + // Assert + assertThat(clicked).isEqualTo(Triple(walletId, currency, earnType)) + } + @Test fun `GIVEN unreachable status WHEN convert THEN both ends are dashes`() { // Arrange diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt index 11341b1c94..32093d1bf2 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTokensActiveConverterTest.kt @@ -24,6 +24,8 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { createTopEarnToken(tokenId = "ethereum", networkRawId = "ETH"), createTopEarnToken(tokenId = "solana", networkRawId = "SOL"), ).right(), + onTokenClick = { _, _, _ -> }, + onAllEarnTokensClick = {}, ) // Act @@ -45,6 +47,8 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { topEarnTokens = List(8) { index -> createTopEarnToken(tokenId = "token-$index", networkRawId = "NET") }.right(), + onTokenClick = { _, _, _ -> }, + onAllEarnTokensClick = {}, ) // Act @@ -69,6 +73,8 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { createTopEarnToken(tokenId = "usd-coin", networkRawId = "ETH"), createTopEarnToken(tokenId = "usd-coin", networkRawId = "SOL"), ).right(), + onTokenClick = { _, _, _ -> }, + onAllEarnTokensClick = {}, ) // Act @@ -80,9 +86,12 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { @Test fun `GIVEN no top tokens loaded WHEN convert THEN content with empty suggestions`() { - // Arrange + // Arrange — the callback instance is shared with `expected` so the whole-object equality holds + val onAllEarnTokensClick: () -> Unit = {} val converter = ForYouEarnOpportunitiesTokensActiveConverter( topEarnTokens = null, + onTokenClick = { _, _, _ -> }, + onAllEarnTokensClick = onAllEarnTokensClick, ) // Act @@ -94,6 +103,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active, potentialReward = null, potentialRewardType = null, + onAllEarnTokensClick = onAllEarnTokensClick, ) assertThat(result).isEqualTo(expected) } @@ -103,6 +113,8 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest { // Arrange val converter = ForYouEarnOpportunitiesTokensActiveConverter( topEarnTokens = EarnError.NotHttpError().left(), + onTokenClick = { _, _, _ -> }, + onAllEarnTokensClick = {}, ) // Act diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt index 8bd359680d..6a15de523f 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/earnOpportunities/ForYouEarnOpportunitiesTopTokenRowConverterTest.kt @@ -9,13 +9,16 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnType +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType import org.junit.jupiter.api.Test import java.math.BigDecimal internal class ForYouEarnOpportunitiesTopTokenRowConverterTest { - private val converter = ForYouEarnOpportunitiesTopTokenRowConverter() + private val converter = ForYouEarnOpportunitiesTopTokenRowConverter(onTokenClick = { _, _, _ -> }) @Test fun `GIVEN top-earn token WHEN convert THEN row carries currency identity and network subtitle`() { @@ -77,6 +80,25 @@ internal class ForYouEarnOpportunitiesTopTokenRowConverterTest { ) } + @Test + fun `GIVEN yield token row clicked WHEN convert THEN callback gets null wallet and yield type with raw apy`() { + // Arrange — suggestions are tokens the user doesn't hold, so no wallet id is forwarded + val topToken = createTopEarnToken(type = EarnType.YIELD, apy = "7.25") + var clicked: Triple? = null + val converter = ForYouEarnOpportunitiesTopTokenRowConverter( + onTokenClick = { id, currency, type -> clicked = Triple(id, currency, type) }, + ) + + // Act + val result = converter.convert(topToken) + (result.tokenRowUM as TangemTokenRowUM.Content).onItemClick?.invoke() + + // Assert + assertThat(clicked).isEqualTo( + Triple(null, topToken.cryptoCurrency, ForYouEarnOpportunitiesType.YieldSupply(apy = "7.25")), + ) + } + @Test fun `GIVEN top-earn token WHEN convert THEN item is a flat non-expandable row`() { // Act diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt index 0b2cbd80b5..c8c458b12a 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt @@ -145,6 +145,7 @@ internal class SetPortfolioReviewTransformerTest { subtitleRes = 0, potentialReward = null, potentialRewardType = null, + onAllEarnTokensClick = {}, ) private fun accountStatusList(totalFiatBalance: TotalFiatBalance): AccountStatusList = mockk {