Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-15 21:40:39 +05:00
commit d894698a9b
25 changed files with 597 additions and 24 deletions

View file

@ -177,6 +177,10 @@ internal class FeedEntryChildFactory @Inject constructor(
token = TokenSummaryComponent.Token.Portfolio(currency), token = TokenSummaryComponent.Token.Portfolio(currency),
) )
} }
override fun onAllEarnTokensClick() {
feedEntryClickIntents.onOpenEarnPage()
}
}, },
), ),
) )

View file

@ -13,6 +13,7 @@ interface ForYouComponent : ComposableModularBottomSheetContentComponent {
interface ForYouModelCallbacks { interface ForYouModelCallbacks {
fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency)
fun onAllEarnTokensClick()
} }
interface Factory : ComponentFactory<Params, ForYouComponent> interface Factory : ComponentFactory<Params, ForYouComponent>

View file

@ -12,12 +12,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.AppComponentContext
import com.tangem.core.decompose.context.child 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.decompose.model.getOrCreateModel
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem 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.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle 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.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20 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.ForYouComponent
import com.tangem.features.foryou.impl.entity.ForYouBottomSheetConfig
import com.tangem.features.foryou.impl.model.ForYouModel import com.tangem.features.foryou.impl.model.ForYouModel
import com.tangem.features.foryou.impl.ui.ForYouContent import com.tangem.features.foryou.impl.ui.ForYouContent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.promobanners.api.PromoBannersBlockComponent
@ -37,6 +46,8 @@ internal class DefaultForYouComponent @AssistedInject constructor(
@Assisted context: AppComponentContext, @Assisted context: AppComponentContext,
@Assisted params: ForYouComponent.Params, @Assisted params: ForYouComponent.Params,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
private val manageFundsComponentFactory: ManageFundsComponent.Factory,
) : AppComponentContext by context, ForYouComponent { ) : AppComponentContext by context, ForYouComponent {
private val model: ForYouModel = getOrCreateModel(params = params) 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 @Composable
override fun Title(bottomSheetState: State<BottomSheetState>) { override fun Title(bottomSheetState: State<BottomSheetState>) {
TangemTopBar( TangemTopBar(
@ -82,6 +105,7 @@ internal class DefaultForYouComponent @AssistedInject constructor(
modifier: Modifier, modifier: Modifier,
) { ) {
val uiState by model.uiState.collectAsStateWithLifecycle() val uiState by model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
ForYouContent( ForYouContent(
forYouUM = uiState, forYouUM = uiState,
@ -90,8 +114,31 @@ internal class DefaultForYouComponent @AssistedInject constructor(
contentPadding = contentPadding, contentPadding = contentPadding,
modifier = modifier, 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 @AssistedFactory
interface Factory : ForYouComponent.Factory { interface Factory : ForYouComponent.Factory {
override fun create(context: AppComponentContext, params: ForYouComponent.Params): DefaultForYouComponent override fun create(context: AppComponentContext, params: ForYouComponent.Params): DefaultForYouComponent

View file

@ -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
}

View file

@ -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
}

View file

@ -62,6 +62,7 @@ internal sealed interface EarnOpportunitiesUM {
@param:StringRes val subtitleRes: Int, @param:StringRes val subtitleRes: Int,
val potentialReward: TextReference?, val potentialReward: TextReference?,
val potentialRewardType: TextReference?, val potentialRewardType: TextReference?,
val onAllEarnTokensClick: () -> Unit,
) : EarnOpportunitiesUM ) : EarnOpportunitiesUM
} }

View file

@ -4,6 +4,12 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse import arrow.core.getOrElse
import arrow.core.left import arrow.core.left
import arrow.core.right 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.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer 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.models.wallet.UserWalletId
import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase 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.ForYouComponent
import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.* import com.tangem.features.foryou.impl.entity.*
@ -38,7 +45,11 @@ import com.tangem.utils.coroutines.combine6
import com.tangem.utils.transformer.update import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.job
import javax.inject.Inject import javax.inject.Inject
@Stable @Stable
@ -49,12 +60,14 @@ internal class ForYouModel @Inject constructor(
userWalletsListRepository: UserWalletsListRepository, userWalletsListRepository: UserWalletsListRepository,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier, multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase, yieldSupplyApyFlowUseCase: YieldSupplyApyFlowUseCase,
private val router: AppRouter,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase, private val getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase,
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase, private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val earnErrorResolver: EarnErrorResolver, private val earnErrorResolver: EarnErrorResolver,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
) : Model() { ) : Model() {
private val params = paramsContainer.require<ForYouComponent.Params>() private val params = paramsContainer.require<ForYouComponent.Params>()
@ -63,6 +76,13 @@ internal class ForYouModel @Inject constructor(
private val expandedEarnOpportunitiesAssetIds = MutableStateFlow<Set<String>>(value = emptySet()) private val expandedEarnOpportunitiesAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow() private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
val bottomSheetNavigation: SlotNavigation<ForYouBottomSheetConfig> = SlotNavigation()
var addToPortfolioManager: AddToPortfolioManager? = null
private set
private var addToPortfolioManagerScope: CoroutineScope? = null
val uiState: StateFlow<ForYouUM> val uiState: StateFlow<ForYouUM>
field = MutableStateFlow<ForYouUM>( field = MutableStateFlow<ForYouUM>(
ForYouUM( ForYouUM(
@ -144,6 +164,8 @@ internal class ForYouModel @Inject constructor(
topEarnTokens = topEarnTokens, topEarnTokens = topEarnTokens,
expandedAssetIds = expandedEarnOpportunities, expandedAssetIds = expandedEarnOpportunities,
expandClick = ::onExpandEarnOpportunitiesClick, expandClick = ::onExpandEarnOpportunitiesClick,
onTokenClick = ::onEarnOpportunitiesTokenClick,
onAllEarnTokensClick = params.callbacks::onAllEarnTokensClick,
).convert(accountStatusList) ).convert(accountStatusList)
uiState.update( uiState.update(
@ -207,6 +229,46 @@ internal class ForYouModel @Inject constructor(
params.callbacks.onTokenClick(walletId, currency) 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) { private fun onExpandPortfolioReviewClick(assetId: String) {
expandedPortfolioReviewAssetIds.update { ids -> expandedPortfolioReviewAssetIds.update { ids ->
if (assetId in ids) ids - assetId else ids + assetId 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,
)
}
},
)
}
} }

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType
import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.isZero
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode 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 isActive whether the user already earns on the token (active yield supply or stake)
* @property apy rate as a fraction (0.05 = 5%) * @property apy rate as a fraction (0.05 = 5%)
* @property potentialRewards projected yearly reward in fiat (`fiatAmount * apy`), `null` when unknown * @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( internal data class EarnApyInfo(
val isActive: Boolean, val isActive: Boolean,
val apy: BigDecimal?, val apy: BigDecimal?,
val potentialRewards: BigDecimal?, val potentialRewards: BigDecimal?,
val type: ForYouEarnOpportunitiesType,
) )
/** /**

View file

@ -8,12 +8,15 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.earn.EarnTopToken import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.staking.StakingBalance 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.StakingAvailability
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.common.RewardInfo import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.staking.model.optionOrNull
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM 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.EarnApyInfo
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
import com.tangem.features.foryou.impl.model.converter.PERCENT_BASE import com.tangem.features.foryou.impl.model.converter.PERCENT_BASE
@ -45,6 +48,8 @@ internal class ForYouEarnOpportunitiesConverter(
private val yieldSupplyAvailability: Map<String, BigDecimal>, private val yieldSupplyAvailability: Map<String, BigDecimal>,
private val yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability>, private val yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability>,
private val topEarnTokens: EarnTopToken?, private val topEarnTokens: EarnTopToken?,
private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit,
private val onAllEarnTokensClick: () -> Unit,
) : Converter<AccountStatusList?, EarnOpportunitiesUM> { ) : Converter<AccountStatusList?, EarnOpportunitiesUM> {
override fun convert(value: AccountStatusList?): EarnOpportunitiesUM { override fun convert(value: AccountStatusList?): EarnOpportunitiesUM {
@ -86,19 +91,26 @@ internal class ForYouEarnOpportunitiesConverter(
data.isEmpty() -> { data.isEmpty() -> {
ForYouEarnOpportunitiesNoTokensConverter( ForYouEarnOpportunitiesNoTokensConverter(
topEarnTokens = topEarnTokens, topEarnTokens = topEarnTokens,
onTokenClick = onTokenClick,
onAllEarnTokensClick = onAllEarnTokensClick,
).convert(data) ).convert(data)
} }
data.all { earn -> earn.earnCurrencies.all { entry -> entry.value.isActive } } -> { data.all { earn -> earn.earnCurrencies.all { entry -> entry.value.isActive } } -> {
ForYouEarnOpportunitiesTokensActiveConverter( ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = topEarnTokens, topEarnTokens = topEarnTokens,
onTokenClick = onTokenClick,
onAllEarnTokensClick = onAllEarnTokensClick,
).convert(data) ).convert(data)
} }
else -> { else -> {
ForYouEarnOpportunitiesPotentialRewardsConverter( ForYouEarnOpportunitiesPotentialRewardsConverter(
appCurrency = appCurrency, appCurrency = appCurrency,
userWalletId = value?.userWalletId,
isAccountsModeEnabled = isAccountsModeEnabled, isAccountsModeEnabled = isAccountsModeEnabled,
expandedAssetIds = expandedAssetIds, expandedAssetIds = expandedAssetIds,
expandClick = expandClick, expandClick = expandClick,
onTokenClick = onTokenClick,
onAllEarnTokensClick = onAllEarnTokensClick,
).convert(data) ).convert(data)
} }
} }
@ -129,6 +141,7 @@ internal class ForYouEarnOpportunitiesConverter(
isActive = isActive, isActive = isActive,
potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(apy), potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(apy),
apy = apy, apy = apy,
type = ForYouEarnOpportunitiesType.YieldSupply(yieldSupplyApy.toPlainString()),
) )
} }
} }
@ -143,6 +156,7 @@ internal class ForYouEarnOpportunitiesConverter(
isActive = stakingInfo.isActive, isActive = stakingInfo.isActive,
apy = stakingInfo.rate, apy = stakingInfo.rate,
potentialRewards = cryptoCurrencyStatus.value.fiatAmount?.multiply(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, rate = rateInfo.rate,
isActive = isActive, isActive = isActive,
rewardType = rateInfo.type, rewardType = rateInfo.type,
integrationId = option.integrationId,
) )
} }
@ -212,5 +227,6 @@ internal class ForYouEarnOpportunitiesConverter(
val rate: BigDecimal, val rate: BigDecimal,
val isActive: Boolean, val isActive: Boolean,
val rewardType: RewardType, val rewardType: RewardType,
val integrationId: StakingIntegrationID,
) )
} }

View file

@ -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.format
import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.parseBigDecimalOrNull 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.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.entity.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities 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.FOR_YOU_TOP_EARN_TOKENS_COUNT
@ -20,6 +23,8 @@ import kotlinx.collections.immutable.toPersistentList
*/ */
internal class ForYouEarnOpportunitiesNoTokensConverter( internal class ForYouEarnOpportunitiesNoTokensConverter(
private val topEarnTokens: EarnTopToken?, private val topEarnTokens: EarnTopToken?,
private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit,
private val onAllEarnTokensClick: () -> Unit,
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> { ) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM { override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
@ -29,7 +34,7 @@ internal class ForYouEarnOpportunitiesNoTokensConverter(
val topEarnToken = topEarnTokenList?.firstOrNull()?.earnToken val topEarnToken = topEarnTokenList?.firstOrNull()?.earnToken
val topEarnApy = topEarnToken?.apy?.parseBigDecimalOrNull() val topEarnApy = topEarnToken?.apy?.parseBigDecimalOrNull()
val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter() val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter(onTokenClick = onTokenClick)
return EarnOpportunitiesUM.Content( return EarnOpportunitiesUM.Content(
tokenList = topEarnTokenList tokenList = topEarnTokenList
@ -39,6 +44,7 @@ internal class ForYouEarnOpportunitiesNoTokensConverter(
subtitleRes = R.string.for_you_earn_opportunities_no_available_tokens, subtitleRes = R.string.for_you_earn_opportunities_no_available_tokens,
potentialReward = stringReference(topEarnApy.format { percent() }), potentialReward = stringReference(topEarnApy.format { percent() }),
potentialRewardType = topEarnToken?.rewardType?.name?.let(::stringReference), potentialRewardType = topEarnToken?.rewardType?.name?.let(::stringReference),
onAllEarnTokensClick = onAllEarnTokensClick,
) )
} }
} }

View file

@ -11,7 +11,10 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account 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.EarnOpportunitiesUM
import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns
@ -29,14 +32,22 @@ import java.math.BigDecimal
* [ForYouEarnOpportunitiesTokenRowConverter], expansion keyed by account id); with it off, the * [ForYouEarnOpportunitiesTokenRowConverter], expansion keyed by account id); with it off, the
* tokens are rendered as flat non-expandable rows. * tokens are rendered as flat non-expandable rows.
*/ */
@Suppress("LongParameterList")
internal class ForYouEarnOpportunitiesPotentialRewardsConverter( internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
private val appCurrency: AppCurrency, private val appCurrency: AppCurrency,
private val userWalletId: UserWalletId?,
private val isAccountsModeEnabled: Boolean, private val isAccountsModeEnabled: Boolean,
private val expandedAssetIds: Set<String>, private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit, private val expandClick: (assetId: String) -> Unit,
private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit,
private val onAllEarnTokensClick: () -> Unit,
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> { ) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
private val rowConverter = ForYouEarnOpportunitiesTokenRowConverter(appCurrency = appCurrency) private val rowConverter = ForYouEarnOpportunitiesTokenRowConverter(
appCurrency = appCurrency,
userWalletId = userWalletId,
onTokenClick = onTokenClick,
)
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM { override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
val totalPotentialReward = value.sumOf { it.accountPotentialReward } val totalPotentialReward = value.sumOf { it.accountPotentialReward }
@ -82,6 +93,7 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverter(
subtitleRes = R.string.for_you_earn_opportunities_tokens_rewards, subtitleRes = R.string.for_you_earn_opportunities_tokens_rewards,
potentialReward = totalPotentialRewardText, potentialReward = totalPotentialRewardText,
potentialRewardType = null, potentialRewardType = null,
onAllEarnTokensClick = onAllEarnTokensClick,
) )
} }

View file

@ -14,7 +14,10 @@ import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource 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.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.features.foryou.impl.model.converter.EarnApyInfo
import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
@ -31,6 +34,8 @@ import java.math.BigDecimal
*/ */
internal class ForYouEarnOpportunitiesTokenRowConverter( internal class ForYouEarnOpportunitiesTokenRowConverter(
private val appCurrency: AppCurrency, private val appCurrency: AppCurrency,
private val userWalletId: UserWalletId?,
private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit,
) : Converter<Pair<CryptoCurrencyStatus, EarnApyInfo>, TangemTokenRowUM> { ) : Converter<Pair<CryptoCurrencyStatus, EarnApyInfo>, TangemTokenRowUM> {
private val iconConverter = CryptoCurrencyToIconStateConverter() private val iconConverter = CryptoCurrencyToIconStateConverter()
@ -55,7 +60,13 @@ internal class ForYouEarnOpportunitiesTokenRowConverter(
), ),
topEndContentUM = toRowTopEnd(cryptoCurrencyStatus, possibleEarnAmount), topEndContentUM = toRowTopEnd(cryptoCurrencyStatus, possibleEarnAmount),
bottomEndContentUM = toRowBottomEnd(cryptoCurrencyStatus, earnApyInfo.apy.orZero()), bottomEndContentUM = toRowBottomEnd(cryptoCurrencyStatus, earnApyInfo.apy.orZero()),
onItemClick = null, onItemClick = {
onTokenClick(
userWalletId,
cryptoCurrencyStatus.currency,
earnApyInfo.type,
)
},
onItemLongClick = null, onItemLongClick = null,
) )
} }

View file

@ -1,8 +1,11 @@
package com.tangem.features.foryou.impl.model.converter.earnOpportunities package com.tangem.features.foryou.impl.model.converter.earnOpportunities
import com.tangem.common.ui.R 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.earn.EarnTopToken
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM 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.EarnOpportunities
import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT import com.tangem.features.foryou.impl.model.converter.FOR_YOU_TOP_EARN_TOKENS_COUNT
import com.tangem.features.foryou.impl.model.converter.forYouEarnAssetKey import com.tangem.features.foryou.impl.model.converter.forYouEarnAssetKey
@ -20,6 +23,8 @@ import kotlinx.collections.immutable.toPersistentList
*/ */
internal class ForYouEarnOpportunitiesTokensActiveConverter( internal class ForYouEarnOpportunitiesTokensActiveConverter(
private val topEarnTokens: EarnTopToken?, private val topEarnTokens: EarnTopToken?,
private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit,
private val onAllEarnTokensClick: () -> Unit,
) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> { ) : Converter<List<EarnOpportunities>, EarnOpportunitiesUM> {
override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM { override fun convert(value: List<EarnOpportunities>): EarnOpportunitiesUM {
@ -28,7 +33,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverter(
.map { status -> status.currency.forYouEarnAssetKey() } .map { status -> status.currency.forYouEarnAssetKey() }
.toSet() .toSet()
val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter() val rowConverter = ForYouEarnOpportunitiesTopTokenRowConverter(onTokenClick)
return EarnOpportunitiesUM.Content( return EarnOpportunitiesUM.Content(
tokenList = topEarnTokens?.getOrNull() tokenList = topEarnTokens?.getOrNull()
@ -40,6 +45,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverter(
subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active, subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active,
potentialReward = null, potentialReward = null,
potentialRewardType = null, potentialRewardType = null,
onAllEarnTokensClick = onAllEarnTokensClick,
) )
} }
} }

View file

@ -10,8 +10,12 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent 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.EarnTokenWithCurrency
import com.tangem.domain.models.earn.EarnType 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.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -23,7 +27,9 @@ import java.math.BigDecimal
* converters that surface suggestions ([ForYouEarnOpportunitiesNoTokensConverter], * converters that surface suggestions ([ForYouEarnOpportunitiesNoTokensConverter],
* [ForYouEarnOpportunitiesTokensActiveConverter]). * [ForYouEarnOpportunitiesTokensActiveConverter]).
*/ */
internal class ForYouEarnOpportunitiesTopTokenRowConverter : Converter<EarnTokenWithCurrency, ForYouTokenListItemUM> { internal class ForYouEarnOpportunitiesTopTokenRowConverter(
private val onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit,
) : Converter<EarnTokenWithCurrency, ForYouTokenListItemUM> {
private val iconConverter = CryptoCurrencyToIconStateConverter() private val iconConverter = CryptoCurrencyToIconStateConverter()
@ -57,7 +63,22 @@ internal class ForYouEarnOpportunitiesTopTokenRowConverter : Converter<EarnToken
}, },
), ),
), ),
onItemClick = {}, onItemClick = {
val type = when (earnToken.type) {
EarnType.STAKING -> {
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 = { _, _ -> }, onItemLongClick = { _, _ -> },
), ),
tokenList = persistentListOf(), tokenList = persistentListOf(),

View file

@ -3,6 +3,7 @@ package com.tangem.features.foryou.impl.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent 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.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp 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.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.extensions.* import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
@ -84,6 +86,20 @@ internal fun ForYouEarnOpportunities(earnOpportunitiesUM: EarnOpportunitiesUM, m
tokenList = earnOpportunitiesUM.tokenList, tokenList = earnOpportunitiesUM.tokenList,
modifier = Modifier.padding(top = 8.dp), 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),
)
}
} }
} }

View file

@ -36,6 +36,7 @@ internal object ForYouEarnOpportunitiesPreviewData {
bottomEnd = "6,2%", bottomEnd = "6,2%",
), ),
), ),
onAllEarnTokensClick = {},
) )
/** No earnable tokens in the portfolio: top earn tokens teaser. */ /** No earnable tokens in the portfolio: top earn tokens teaser. */
@ -52,6 +53,7 @@ internal object ForYouEarnOpportunitiesPreviewData {
bottomEnd = "Staking", bottomEnd = "Staking",
), ),
), ),
onAllEarnTokensClick = {},
) )
/** Every earnable token is already earning: plain subtitle, no reward badge, no rows. */ /** Every earnable token is already earning: plain subtitle, no reward badge, no rows. */
@ -60,6 +62,7 @@ internal object ForYouEarnOpportunitiesPreviewData {
potentialReward = null, potentialReward = null,
potentialRewardType = null, potentialRewardType = null,
tokenList = persistentListOf(), tokenList = persistentListOf(),
onAllEarnTokensClick = {},
) )
val loading = EarnOpportunitiesUM.Loading( val loading = EarnOpportunitiesUM.Loading(

View file

@ -2,6 +2,8 @@ package com.tangem.features.foryou.impl.model
import arrow.core.right import arrow.core.right
import com.google.common.truth.Truth.assertThat 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.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM 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.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.earn.EarnError import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.earn.EarnRewardType import com.tangem.domain.models.earn.*
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.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId 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.staking.usecase.StakingAvailabilityListUseCase
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyFlowUseCase 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.ForYouComponent
import com.tangem.features.foryou.impl.components.state.MarketChartUM import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.EarnOpportunitiesUM 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.pagination.PaginationStatus
import com.tangem.test.mock.MockAccounts import com.tangem.test.mock.MockAccounts
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.CapturingSlot import io.mockk.*
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -72,6 +70,8 @@ internal class ForYouModelTest {
private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase = mockk() private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase = mockk()
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
private val earnErrorResolver: EarnErrorResolver = mockk() private val earnErrorResolver: EarnErrorResolver = mockk()
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory = mockk()
private val router: AppRouter = mockk(relaxUnitFun = true)
private var model: ForYouModel? = null 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 = private fun PortfolioReviewUM.Content.assetRow(): TangemTokenRowUM.Content =
tokenList.single().tokenRowUM as 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. */ /** Wires the repository + supplier so the model derives Content from a single selected wallet. */
private fun stubSelectedWallet( private fun stubSelectedWallet(
currencies: List<CryptoCurrencyStatus>, currencies: List<CryptoCurrencyStatus>,
@ -343,18 +401,21 @@ internal class ForYouModelTest {
ForYouComponent.Params( ForYouComponent.Params(
callbacks = object : ForYouComponent.ForYouModelCallbacks { callbacks = object : ForYouComponent.ForYouModelCallbacks {
override fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) = Unit override fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) = Unit
override fun onAllEarnTokensClick() = Unit
}, },
), ),
), ),
userWalletsListRepository = userWalletsListRepository, userWalletsListRepository = userWalletsListRepository,
multiAccountStatusListSupplier = multiAccountStatusListSupplier, multiAccountStatusListSupplier = multiAccountStatusListSupplier,
yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase, yieldSupplyApyFlowUseCase = yieldSupplyApyFlowUseCase,
router = router,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(), dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
getEarnTokensBatchFlowUseCase = getEarnTokensBatchFlowUseCase, getEarnTokensBatchFlowUseCase = getEarnTokensBatchFlowUseCase,
stakingAvailabilityListUseCase = stakingAvailabilityListUseCase, stakingAvailabilityListUseCase = stakingAvailabilityListUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
earnErrorResolver = earnErrorResolver, earnErrorResolver = earnErrorResolver,
addToPortfolioManagerFactory = addToPortfolioManagerFactory,
).also { model = it } ).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 { private fun createNetwork(networkRawId: String): Network {
val networkId: Network.ID = mockk { val networkId: Network.ID = mockk {
every { rawId } returns Network.RawID(networkRawId) every { rawId } returns Network.RawID(networkRawId)

View file

@ -13,6 +13,7 @@ import com.tangem.domain.models.earn.EarnType
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.yield.supply.YieldSupplyStatus 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.EarnApyInfo
import com.tangem.features.foryou.impl.model.converter.EarnOpportunities import com.tangem.features.foryou.impl.model.converter.EarnOpportunities
import com.tangem.test.mock.MockAccounts import com.tangem.test.mock.MockAccounts
@ -180,7 +181,8 @@ internal fun createEarnApyInfo(
isActive: Boolean = true, isActive: Boolean = true,
apy: BigDecimal? = BigDecimal("0.05"), apy: BigDecimal? = BigDecimal("0.05"),
potentialRewards: BigDecimal? = null, 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( internal fun createPortfolioStatus(
currencies: List<CryptoCurrencyStatus>, currencies: List<CryptoCurrencyStatus>,
@ -192,4 +194,5 @@ internal fun createPortfolioStatus(
internal fun createAccountStatusList(vararg statuses: AccountStatus): AccountStatusList = mockk { internal fun createAccountStatusList(vararg statuses: AccountStatus): AccountStatusList = mockk {
every { accountStatuses } returns statuses.toList() every { accountStatuses } returns statuses.toList()
every { userWalletId } returns MockAccounts.userWalletId
} }

View file

@ -15,12 +15,14 @@ import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.earn.EarnTopToken import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.staking.BalanceItem import com.tangem.domain.models.staking.BalanceItem
import com.tangem.domain.models.staking.StakingBalance 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.StakingAvailability
import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.common.RewardInfo import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.model.stakekit.Yield 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.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.test.mock.MockAccounts import com.tangem.test.mock.MockAccounts
import io.mockk.every 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 @Nested
inner class AccountOrdering { inner class AccountOrdering {
@ -328,6 +379,7 @@ internal class ForYouEarnOpportunitiesConverterTest {
yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability> = emptyMap(), yieldStakingAvailability: Map<CryptoCurrency, StakingAvailability> = emptyMap(),
topEarnTokens: EarnTopToken? = null, topEarnTokens: EarnTopToken? = null,
isAccountsModeEnabled: Boolean = false, isAccountsModeEnabled: Boolean = false,
onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit = { _, _, _ -> },
) = ForYouEarnOpportunitiesConverter( ) = ForYouEarnOpportunitiesConverter(
appCurrency = appCurrency, appCurrency = appCurrency,
isAccountsModeEnabled = isAccountsModeEnabled, isAccountsModeEnabled = isAccountsModeEnabled,
@ -336,19 +388,23 @@ internal class ForYouEarnOpportunitiesConverterTest {
yieldSupplyAvailability = yieldSupplyAvailability, yieldSupplyAvailability = yieldSupplyAvailability,
yieldStakingAvailability = yieldStakingAvailability, yieldStakingAvailability = yieldStakingAvailability,
topEarnTokens = topEarnTokens, 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 { private fun stakingOption(apy: BigDecimal): StakingOption.P2PEthPool = mockk {
every { this@mockk.apy } returns apy every { this@mockk.apy } returns apy
every { integrationId } returns StakingIntegrationID.P2PEthPool
} }
private fun stakingAvailable(apy: BigDecimal): StakingAvailability = private fun stakingAvailable(apy: BigDecimal): StakingAvailability =
StakingAvailability.Available(option = stakingOption(apy)) StakingAvailability.Available(option = stakingOption(apy))
private fun stakeKitAvailable(vararg validatorList: Yield.Validator): StakingAvailability { private fun stakeKitAvailable(vararg validatorList: Yield.Validator): StakingAvailability {
// A real StakeKit option with a real integration id: stubbing `integrationId` on a mock would // A real StakeKit option: stubbing `integrationId` on a mock would make mockk instrument
// make mockk instrument StakingIntegrationID.StakeKit, whose implementations are enums that the // StakingIntegrationID.StakeKit, hitting the same enum-retransformation limitation.
// JVM refuses to retransform ("cannot change the class modifiers").
val yieldModel: Yield = mockk { val yieldModel: Yield = mockk {
every { validators } returns validatorList.toList() every { validators } returns validatorList.toList()
every { apy } returns BigDecimal("0.10") every { apy } returns BigDecimal("0.10")

View file

@ -20,6 +20,8 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest {
topEarnTokens = List(7) { index -> topEarnTokens = List(7) { index ->
createTopEarnToken(tokenId = "token-$index", networkRawId = "NET") createTopEarnToken(tokenId = "token-$index", networkRawId = "NET")
}.right(), }.right(),
onTokenClick = { _, _, _ -> },
onAllEarnTokensClick = {},
) )
// Act // Act
@ -39,6 +41,8 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest {
createTopEarnToken(apy = "7.25", rewardType = EarnRewardType.APR), createTopEarnToken(apy = "7.25", rewardType = EarnRewardType.APR),
createTopEarnToken(tokenId = "solana", apy = "99.9", rewardType = EarnRewardType.APY), createTopEarnToken(tokenId = "solana", apy = "99.9", rewardType = EarnRewardType.APY),
).right(), ).right(),
onTokenClick = { _, _, _ -> },
onAllEarnTokensClick = {},
) )
// Act // Act
@ -56,6 +60,8 @@ internal class ForYouEarnOpportunitiesNoTokensConverterTest {
// Arrange // Arrange
val converter = ForYouEarnOpportunitiesNoTokensConverter( val converter = ForYouEarnOpportunitiesNoTokensConverter(
topEarnTokens = null, topEarnTokens = null,
onTokenClick = { _, _, _ -> },
onAllEarnTokensClick = {},
) )
// Act // Act

View file

@ -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.fiat
import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency 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.features.foryou.impl.entity.EarnOpportunitiesUM
import com.tangem.test.mock.MockAccounts import com.tangem.test.mock.MockAccounts
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -108,6 +111,32 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest {
assertThat(clickedAssetId).isEqualTo(account.accountId.value) 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<UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType>? = 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 @Test
fun `GIVEN several accounts WHEN convert THEN header reward is the sum across accounts`() { fun `GIVEN several accounts WHEN convert THEN header reward is the sum across accounts`() {
// Arrange // Arrange
@ -137,10 +166,15 @@ internal class ForYouEarnOpportunitiesPotentialRewardsConverterTest {
isAccountsModeEnabled: Boolean, isAccountsModeEnabled: Boolean,
expandedAssetIds: Set<String> = emptySet(), expandedAssetIds: Set<String> = emptySet(),
expandClick: (String) -> Unit = {}, expandClick: (String) -> Unit = {},
userWalletId: UserWalletId? = UserWalletId("01"),
onTokenClick: (UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType) -> Unit = { _, _, _ -> },
) = ForYouEarnOpportunitiesPotentialRewardsConverter( ) = ForYouEarnOpportunitiesPotentialRewardsConverter(
appCurrency = appCurrency, appCurrency = appCurrency,
userWalletId = userWalletId,
isAccountsModeEnabled = isAccountsModeEnabled, isAccountsModeEnabled = isAccountsModeEnabled,
expandedAssetIds = expandedAssetIds, expandedAssetIds = expandedAssetIds,
expandClick = expandClick, expandClick = expandClick,
onTokenClick = onTokenClick,
onAllEarnTokensClick = {},
) )
} }

View file

@ -13,7 +13,10 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource 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.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType
import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -23,7 +26,16 @@ import java.math.BigDecimal
internal class ForYouEarnOpportunitiesTokenRowConverterTest { internal class ForYouEarnOpportunitiesTokenRowConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default 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 @Test
fun `GIVEN loading status WHEN convert THEN row is Loading with currency id`() { 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) 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<UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType>? = 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 @Test
fun `GIVEN unreachable status WHEN convert THEN both ends are dashes`() { fun `GIVEN unreachable status WHEN convert THEN both ends are dashes`() {
// Arrange // Arrange

View file

@ -24,6 +24,8 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
createTopEarnToken(tokenId = "ethereum", networkRawId = "ETH"), createTopEarnToken(tokenId = "ethereum", networkRawId = "ETH"),
createTopEarnToken(tokenId = "solana", networkRawId = "SOL"), createTopEarnToken(tokenId = "solana", networkRawId = "SOL"),
).right(), ).right(),
onTokenClick = { _, _, _ -> },
onAllEarnTokensClick = {},
) )
// Act // Act
@ -45,6 +47,8 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
topEarnTokens = List(8) { index -> topEarnTokens = List(8) { index ->
createTopEarnToken(tokenId = "token-$index", networkRawId = "NET") createTopEarnToken(tokenId = "token-$index", networkRawId = "NET")
}.right(), }.right(),
onTokenClick = { _, _, _ -> },
onAllEarnTokensClick = {},
) )
// Act // Act
@ -69,6 +73,8 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
createTopEarnToken(tokenId = "usd-coin", networkRawId = "ETH"), createTopEarnToken(tokenId = "usd-coin", networkRawId = "ETH"),
createTopEarnToken(tokenId = "usd-coin", networkRawId = "SOL"), createTopEarnToken(tokenId = "usd-coin", networkRawId = "SOL"),
).right(), ).right(),
onTokenClick = { _, _, _ -> },
onAllEarnTokensClick = {},
) )
// Act // Act
@ -80,9 +86,12 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
@Test @Test
fun `GIVEN no top tokens loaded WHEN convert THEN content with empty suggestions`() { 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( val converter = ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = null, topEarnTokens = null,
onTokenClick = { _, _, _ -> },
onAllEarnTokensClick = onAllEarnTokensClick,
) )
// Act // Act
@ -94,6 +103,7 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active, subtitleRes = R.string.for_you_earn_opportunities_all_tokens_active,
potentialReward = null, potentialReward = null,
potentialRewardType = null, potentialRewardType = null,
onAllEarnTokensClick = onAllEarnTokensClick,
) )
assertThat(result).isEqualTo(expected) assertThat(result).isEqualTo(expected)
} }
@ -103,6 +113,8 @@ internal class ForYouEarnOpportunitiesTokensActiveConverterTest {
// Arrange // Arrange
val converter = ForYouEarnOpportunitiesTokensActiveConverter( val converter = ForYouEarnOpportunitiesTokensActiveConverter(
topEarnTokens = EarnError.NotHttpError().left(), topEarnTokens = EarnError.NotHttpError().left(),
onTokenClick = { _, _, _ -> },
onAllEarnTokensClick = {},
) )
// Act // Act

View file

@ -9,13 +9,16 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent 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.earn.EarnType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.impl.entity.ForYouEarnOpportunitiesType
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.math.BigDecimal import java.math.BigDecimal
internal class ForYouEarnOpportunitiesTopTokenRowConverterTest { internal class ForYouEarnOpportunitiesTopTokenRowConverterTest {
private val converter = ForYouEarnOpportunitiesTopTokenRowConverter() private val converter = ForYouEarnOpportunitiesTopTokenRowConverter(onTokenClick = { _, _, _ -> })
@Test @Test
fun `GIVEN top-earn token WHEN convert THEN row carries currency identity and network subtitle`() { 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<UserWalletId?, CryptoCurrency, ForYouEarnOpportunitiesType>? = 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 @Test
fun `GIVEN top-earn token WHEN convert THEN item is a flat non-expandable row`() { fun `GIVEN top-earn token WHEN convert THEN item is a flat non-expandable row`() {
// Act // Act

View file

@ -145,6 +145,7 @@ internal class SetPortfolioReviewTransformerTest {
subtitleRes = 0, subtitleRes = 0,
potentialReward = null, potentialReward = null,
potentialRewardType = null, potentialRewardType = null,
onAllEarnTokensClick = {},
) )
private fun accountStatusList(totalFiatBalance: TotalFiatBalance): AccountStatusList = mockk { private fun accountStatusList(totalFiatBalance: TotalFiatBalance): AccountStatusList = mockk {