Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 13:27:31 +05:00
parent 48137d6199
commit aae00c8dd5
18 changed files with 308 additions and 3 deletions

View file

@ -332,6 +332,8 @@ dependencies {
implementation(projects.features.yieldSupply.impl) implementation(projects.features.yieldSupply.impl)
implementation(projects.features.approval.api) implementation(projects.features.approval.api)
implementation(projects.features.approval.impl) implementation(projects.features.approval.impl)
implementation(projects.features.forYou.api)
implementation(projects.features.forYou.impl)
/** AndroidX libraries */ /** AndroidX libraries */
implementation(deps.androidx.core.ktx) implementation(deps.androidx.core.ktx)

View file

@ -1550,6 +1550,7 @@
<string name="staking_enabled">Staking aktiviert</string> <string name="staking_enabled">Staking aktiviert</string>
<string name="staking_error_no_validators_message">Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal.</string> <string name="staking_error_no_validators_message">Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal.</string>
<string name="staking_error_no_validators_title">Staking nicht verfügbar</string> <string name="staking_error_no_validators_title">Staking nicht verfügbar</string>
<string name="staking_error_unavailable_region">Staking ist in Ihrer Region nicht verfügbar.</string>
<string name="staking_give_permission_fee_footer">Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst.</string> <string name="staking_give_permission_fee_footer">Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst.</string>
<string name="staking_legal">Indem Du die Staking-Funktionalität nutzt, stimmst Du den %1$s und %2$s des Anbieters zu.</string> <string name="staking_legal">Indem Du die Staking-Funktionalität nutzt, stimmst Du den %1$s und %2$s des Anbieters zu.</string>
<string name="staking_locked">Gesperrt</string> <string name="staking_locked">Gesperrt</string>

View file

@ -1421,6 +1421,7 @@
<string name="staking_enabled">Staking activé</string> <string name="staking_enabled">Staking activé</string>
<string name="staking_error_no_validators_message">Aucun validateur disponible pour le moment. Veuillez réessayer plus tard.</string> <string name="staking_error_no_validators_message">Aucun validateur disponible pour le moment. Veuillez réessayer plus tard.</string>
<string name="staking_error_no_validators_title">Staking indisponible</string> <string name="staking_error_no_validators_title">Staking indisponible</string>
<string name="staking_error_unavailable_region">Le staking est indisponible dans votre région</string>
<string name="staking_give_permission_fee_footer">Le réseau facturera des frais dapprobation de jeton pour vérifier que vous autorisez lutilisation de votre jeton pour le jalonnement.</string> <string name="staking_give_permission_fee_footer">Le réseau facturera des frais dapprobation de jeton pour vérifier que vous autorisez lutilisation de votre jeton pour le jalonnement.</string>
<string name="staking_legal">En utilisant la fonctionnalité de staking, vous acceptez les %1$s et %2$s du fournisseur</string> <string name="staking_legal">En utilisant la fonctionnalité de staking, vous acceptez les %1$s et %2$s du fournisseur</string>
<string name="staking_locked">Bloqué</string> <string name="staking_locked">Bloqué</string>

View file

@ -1520,6 +1520,7 @@
<string name="staking_enabled">ステーキングが有効です</string> <string name="staking_enabled">ステーキングが有効です</string>
<string name="staking_error_no_validators_message">現在、利用可能なバリデーターは見つかりません。しばらくしてからもう一度お試しください。</string> <string name="staking_error_no_validators_message">現在、利用可能なバリデーターは見つかりません。しばらくしてからもう一度お試しください。</string>
<string name="staking_error_no_validators_title">ステーキングは利用できません</string> <string name="staking_error_no_validators_title">ステーキングは利用できません</string>
<string name="staking_error_unavailable_region">お住まいの地域ではステーキングをご利用いただけません</string>
<string name="staking_give_permission_fee_footer">ネットワークは、ステーキングのためにトークンの使用を承認していることを確認するために、トークン承認手数料を請求します。</string> <string name="staking_give_permission_fee_footer">ネットワークは、ステーキングのためにトークンの使用を承認していることを確認するために、トークン承認手数料を請求します。</string>
<string name="staking_legal">ステーキング機能を使用すると、プロバイダーの%1$sと%2$sに同意したことになります</string> <string name="staking_legal">ステーキング機能を使用すると、プロバイダーの%1$sと%2$sに同意したことになります</string>
<string name="staking_locked">ロック中</string> <string name="staking_locked">ロック中</string>

View file

@ -1512,6 +1512,7 @@
<string name="staking_enabled">已启用质押</string> <string name="staking_enabled">已启用质押</string>
<string name="staking_error_no_validators_message">目前没有可用的验证节点。请稍后再试。</string> <string name="staking_error_no_validators_message">目前没有可用的验证节点。请稍后再试。</string>
<string name="staking_error_no_validators_title">质押功能不可用</string> <string name="staking_error_no_validators_title">质押功能不可用</string>
<string name="staking_error_unavailable_region">您所在的地区暂不支持质押功能</string>
<string name="staking_give_permission_fee_footer">网络将收取代币批准费,以验证您是否授权使用您的代币进行质押。</string> <string name="staking_give_permission_fee_footer">网络将收取代币批准费,以验证您是否授权使用您的代币进行质押。</string>
<string name="staking_legal">使用质押功能即表示您同意提供商的 %1$s 和 %2$s</string> <string name="staking_legal">使用质押功能即表示您同意提供商的 %1$s 和 %2$s</string>
<string name="staking_locked">已锁定</string> <string name="staking_locked">已锁定</string>

View file

@ -115,10 +115,17 @@
<string name="address_book_enter_address">Enter address</string> <string name="address_book_enter_address">Enter address</string>
<string name="address_book_invalid_address_error">Invalid address</string> <string name="address_book_invalid_address_error">Invalid address</string>
<string name="address_book_keep_editing">Keep editing</string> <string name="address_book_keep_editing">Keep editing</string>
<string name="address_book_max_networks_alert_description">You can not create more than 20 addresses. Delete one to add new.</string>
<string name="address_book_max_networks_alert_title">Can\'t add new address </string>
<string name="address_book_name_empty_error">Contact name is required</string>
<string name="address_book_name_invalid_chars_error">Contact name contains invalid characters</string>
<string name="address_book_name_max_chars_error">Contact name must not exceed 50 characters</string>
<string name="address_book_name_taken_error">That name is already taken on this wallet</string>
<string name="address_book_new_contact">New contact</string> <string name="address_book_new_contact">New contact</string>
<string name="address_book_no_contacts">No contacts yet</string> <string name="address_book_no_contacts">No contacts yet</string>
<string name="address_book_no_contacts_description">Contacts added will appear here</string> <string name="address_book_no_contacts_description">Contacts added will appear here</string>
<string name="address_book_remove_address">Remove address</string> <string name="address_book_remove_address">Remove address</string>
<string name="address_book_save_to_wallet_title">Save to Wallet</string>
<string name="address_book_save_wallet_to_description">This contact will be linked to this wallets address book.</string> <string name="address_book_save_wallet_to_description">This contact will be linked to this wallets address book.</string>
<string name="address_book_select_network">Select network</string> <string name="address_book_select_network">Select network</string>
<string name="address_book_title">Address book</string> <string name="address_book_title">Address book</string>
@ -352,6 +359,7 @@
<string name="common_get_token">Get token</string> <string name="common_get_token">Get token</string>
<string name="common_go_to_provider">Go to provider</string> <string name="common_go_to_provider">Go to provider</string>
<string name="common_go_to_token">Go to token</string> <string name="common_go_to_token">Go to token</string>
<string name="common_go_to_verification">Go to verification</string>
<string name="common_got_it">Got it</string> <string name="common_got_it">Got it</string>
<string name="common_hide">Hide</string> <string name="common_hide">Hide</string>
<string name="common_hold_to">Hold to %s</string> <string name="common_hold_to">Hold to %s</string>
@ -701,6 +709,8 @@
<string name="feedback_subject_support_tangem">Tangem feedback</string> <string name="feedback_subject_support_tangem">Tangem feedback</string>
<string name="feedback_subject_tx_failed">Can\'t send a transaction</string> <string name="feedback_subject_tx_failed">Can\'t send a transaction</string>
<string name="feedback_token_description_error">Coin description error</string> <string name="feedback_token_description_error">Coin description error</string>
<string name="for_you_description">Review portfolio and explore earn opportunities</string>
<string name="for_you_title">For You</string>
<string name="force_update_action">Update now</string> <string name="force_update_action">Update now</string>
<string name="force_update_banner_message">Update the app to its latest version to ensure proper functionality</string> <string name="force_update_banner_message">Update the app to its latest version to ensure proper functionality</string>
<string name="force_update_banner_title">Update needed</string> <string name="force_update_banner_title">Update needed</string>
@ -732,6 +742,8 @@
<string name="hardware_wallet_key_feature_title">Key Generation</string> <string name="hardware_wallet_key_feature_title">Key Generation</string>
<string name="hardware_wallet_security_feature_description">All cryptographic operations happen inside the secure chip, certified against cloning and physical tampering.</string> <string name="hardware_wallet_security_feature_description">All cryptographic operations happen inside the secure chip, certified against cloning and physical tampering.</string>
<string name="hardware_wallet_security_feature_title">Hardware-Level Security</string> <string name="hardware_wallet_security_feature_title">Hardware-Level Security</string>
<string name="high_fee_warning_description">Network activity is high. You can continue now or try again later when fees may be lower.</string>
<string name="high_fee_warning_title">Network fee is higher than usual</string>
<string name="home_button_add_existing_wallet">Add Existing Wallet</string> <string name="home_button_add_existing_wallet">Add Existing Wallet</string>
<string name="home_button_create_new_wallet">Create New Wallet</string> <string name="home_button_create_new_wallet">Create New Wallet</string>
<string name="home_button_order">Order Tangem</string> <string name="home_button_order">Order Tangem</string>
@ -1402,6 +1414,7 @@
<string name="send_extras_hint_memo">Memo</string> <string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_unreachable_error_text">Check your network connection</string> <string name="send_fee_unreachable_error_text">Check your network connection</string>
<string name="send_fee_unreachable_error_title">Network fee info unreachable</string> <string name="send_fee_unreachable_error_title">Network fee info unreachable</string>
<string name="send_from_account_in_wallet_name">from %1$s in %2$s</string>
<string name="send_from_title">You send</string> <string name="send_from_title">You send</string>
<string name="send_from_wallet_name">From %s</string> <string name="send_from_wallet_name">From %s</string>
<string name="send_gas_limit">Gas limit</string> <string name="send_gas_limit">Gas limit</string>

View file

@ -25,6 +25,7 @@ dependencies {
api(projects.features.wallet.api) api(projects.features.wallet.api)
api(projects.features.account.api) api(projects.features.account.api)
api(projects.features.commonFeatures.api) api(projects.features.commonFeatures.api)
api(projects.features.forYou.api)
implementation(projects.features.promoBanners.api) implementation(projects.features.promoBanners.api)
/* Data */ /* Data */

View file

@ -142,6 +142,10 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
override fun openSearch(source: String) { override fun openSearch(source: String) {
stackNavigation.bringToFront(FeedEntryChildFactory.Child.Search(source)) stackNavigation.bringToFront(FeedEntryChildFactory.Child.Search(source))
} }
override fun openForYou() {
stackNavigation.bringToFront(FeedEntryChildFactory.Child.ForYou)
}
} }
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>> = private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>> =

View file

@ -20,6 +20,7 @@ import com.tangem.features.feed.components.news.details.DefaultNewsDetailsCompon
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import com.tangem.features.feed.components.search.DefaultSearchComponent import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import javax.inject.Inject import javax.inject.Inject
@ -33,6 +34,7 @@ internal class FeedEntryChildFactory @Inject constructor(
private val manageFundsComponentFactory: ManageFundsComponent.Factory, private val manageFundsComponentFactory: ManageFundsComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val designFeatureToggles: DesignFeatureToggles, private val designFeatureToggles: DesignFeatureToggles,
private val forYouComponentFactory: ForYouComponent.Factory,
) { ) {
@Serializable @Serializable
@ -66,6 +68,10 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable @Serializable
@Immutable @Immutable
data class Search(val source: String) : Child data class Search(val source: String) : Child
@Serializable
@Immutable
data object ForYou : Child
} }
@Suppress("LongMethod") @Suppress("LongMethod")
@ -147,6 +153,10 @@ internal class FeedEntryChildFactory @Inject constructor(
onSeeAllMarketsClick = { feedEntryClickIntents.onMarketOpenClick(SortByTypeUM.Rating) }, onSeeAllMarketsClick = { feedEntryClickIntents.onMarketOpenClick(SortByTypeUM.Rating) },
), ),
) )
Child.ForYou -> forYouComponentFactory.create(
context = appComponentContext,
params = Unit,
)
} }
} }
} }

View file

@ -14,7 +14,12 @@ 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
import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_heart_28
import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
@ -37,6 +42,7 @@ import com.tangem.features.feed.model.feed.state.transformers.*
import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.* import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.foryou.ForYouFeatureToggles
import com.tangem.utils.Provider import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -61,6 +67,7 @@ internal class FeedComponentModel @Inject constructor(
private val appRouter: AppRouter, private val appRouter: AppRouter,
private val designFeatureToggles: DesignFeatureToggles, private val designFeatureToggles: DesignFeatureToggles,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val forYouFeatureToggles: ForYouFeatureToggles,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
@ -273,6 +280,20 @@ internal class FeedComponentModel @Inject constructor(
), ),
globalState = GlobalFeedState.Loading, globalState = GlobalFeedState.Loading,
earnListUM = EarnListUM.Loading, earnListUM = EarnListUM.Loading,
forYouBannerUM = if (forYouFeatureToggles.isForYouEnabled) {
ForYouBannerUM.Content(
TangemMessageUM(
id = ForYouBannerUM.Content::class.java.simpleName,
title = resourceReference(R.string.for_you_title),
subtitle = resourceReference(R.string.for_you_description),
iconUM = TangemIconUM.Icon(Icons.ic_heart_28), // TODO ForYou update icon,
messageEffect = TangemMessageEffect.Magic,
onClick = params.feedClickIntents::openForYou,
),
)
} else {
ForYouBannerUM.Empty
},
) )
} }

View file

@ -31,4 +31,6 @@ internal interface FeedModelClickIntents {
fun onOpenEarnPage() fun onOpenEarnPage()
fun openSearch(source: String) fun openSearch(source: String)
fun openForYou()
} }

View file

@ -65,6 +65,7 @@ internal class FeedStateController @Inject constructor() {
), ),
globalState = GlobalFeedState.Loading, globalState = GlobalFeedState.Loading,
earnListUM = EarnListUM.Loading, earnListUM = EarnListUM.Loading,
forYouBannerUM = ForYouBannerUM.Empty,
) )
} }
} }

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreview
@ -27,6 +28,7 @@ import com.tangem.features.feed.ui.feed.components.*
import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
import com.tangem.features.feed.ui.feed.state.FeedListUM import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.ForYouBannerUM
import com.tangem.features.feed.ui.feed.state.GlobalFeedState import com.tangem.features.feed.ui.feed.state.GlobalFeedState
@Composable @Composable
@ -105,6 +107,17 @@ private fun FeedListContent(
SpacerH(contentPadding.calculateTopPadding()) SpacerH(contentPadding.calculateTopPadding())
} }
DateBlock(state.currentDate) DateBlock(state.currentDate)
SpacerH(16.dp)
if (state.forYouBannerUM is ForYouBannerUM.Content && LocalRedesignEnabled.current) {
// TODO ForYou replace with message banner
TangemMessage(
messageUM = state.forYouBannerUM.banner,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
SpacerH(32.dp) SpacerH(32.dp)
MarketBlock( MarketBlock(

View file

@ -7,9 +7,15 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_heart_28
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnType import com.tangem.domain.models.earn.EarnType
import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM
@ -56,6 +62,7 @@ internal object FeedListPreviewDataProvider {
earnListUM = EarnListUM.Content( earnListUM = EarnListUM.Content(
items = createEarnListItemsUM(), items = createEarnListItemsUM(),
), ),
forYouBannerUM = createForYouItem(),
) )
} }
@ -285,4 +292,17 @@ internal object FeedListPreviewDataProvider {
) )
}.toPersistentList() }.toPersistentList()
} }
private fun createForYouItem(): ForYouBannerUM {
return ForYouBannerUM.Content(
TangemMessageUM(
id = ForYouBannerUM.Content::class.java.simpleName,
title = resourceReference(R.string.for_you_title),
subtitle = resourceReference(R.string.for_you_description),
iconUM = TangemIconUM.Icon(Icons.ic_heart_28), // TODO ForYou update icon,
messageEffect = TangemMessageEffect.Magic,
onClick = {},
),
)
}
} }

View file

@ -2,10 +2,11 @@ package com.tangem.features.feed.ui.feed.state
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -19,6 +20,7 @@ internal data class FeedListUM(
val marketChartConfig: MarketChartConfig, val marketChartConfig: MarketChartConfig,
val globalState: GlobalFeedState = GlobalFeedState.Content, val globalState: GlobalFeedState = GlobalFeedState.Content,
val earnListUM: EarnListUM, val earnListUM: EarnListUM,
val forYouBannerUM: ForYouBannerUM,
) )
internal data class FeedListCallbacks( internal data class FeedListCallbacks(
@ -80,6 +82,16 @@ internal data class SortChartConfigUM(
val isSelected: Boolean, val isSelected: Boolean,
) )
@Immutable
internal sealed interface ForYouBannerUM {
data class Content(
val banner: TangemMessageUM,
) : ForYouBannerUM
data object Empty : ForYouBannerUM
}
@Immutable @Immutable
internal sealed interface GlobalFeedState { internal sealed interface GlobalFeedState {
data object Loading : GlobalFeedState data object Loading : GlobalFeedState

View file

@ -0,0 +1,166 @@
package com.tangem.features.feed.model.feed
import android.text.format.DateFormat
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.earn.usecase.FetchTopEarnTokensUseCase
import com.tangem.domain.earn.usecase.GetTopEarnTokensUseCase
import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase
import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams
import com.tangem.features.feed.model.feed.state.FeedStateController
import com.tangem.features.feed.ui.feed.state.ForYouBannerUM
import com.tangem.features.foryou.ForYouFeatureToggles
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FeedComponentModelTest {
// --- shared mocks ---
private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase = mockk(relaxed = true)
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase = mockk()
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase = mockk(relaxed = true)
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase = mockk()
private val appRouter: AppRouter = mockk(relaxed = true)
private val designFeatureToggles: DesignFeatureToggles = mockk()
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory = mockk(relaxed = true)
private val getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase = mockk(relaxed = true)
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val feedClickIntents: FeedModelClickIntents = mockk(relaxed = true)
@BeforeEach
fun setUpDateFormatMock() {
// DateTimeFormatters.dateDMMM is a lazy val that calls android.text.format.DateFormat
// .getBestDateTimePattern — an Android stub not available in JVM unit tests.
// Mirror the pattern used in TxHistoryInfoToTxHistoryDetailsUMConverterTest.
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
}
@AfterEach
fun tearDownDateFormatMock() {
unmockkStatic(DateFormat::class)
}
/**
* Builds a [FeedComponentModel] wired into the given [TestScope], using a real
* [FeedStateController] so we can read the initialised state directly.
*
* All deps unrelated to [ForYouFeatureToggles] are relaxed or stubbed with empty flows so
* the model's background coroutines don't throw.
*/
private fun TestScope.createModel(forYouFeatureToggles: ForYouFeatureToggles): FeedComponentModel {
val testDispatcher = StandardTestDispatcher(testScheduler)
val dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
every { getSelectedAppCurrencyUseCase() } returns flowOf(Either.Right(AppCurrency.Default))
every { manageTrendingNewsUseCase.observeTrendingNews() } returns emptyFlow()
every { getTopEarnTokensUseCase() } returns emptyFlow()
every { designFeatureToggles.isRedesignEnabled } returns false
val paramsContainer = MutableParamsContainer(FeedParams(feedClickIntents = feedClickIntents))
return FeedComponentModel(
dispatchers = dispatchers,
fetchTrendingNewsUseCase = fetchTrendingNewsUseCase,
manageTrendingNewsUseCase = manageTrendingNewsUseCase,
analyticsEventHandler = analyticsEventHandler,
stateController = FeedStateController(),
fetchTopEarnTokensUseCase = fetchTopEarnTokensUseCase,
getTopEarnTokensUseCase = getTopEarnTokensUseCase,
appRouter = appRouter,
designFeatureToggles = designFeatureToggles,
addToPortfolioManagerFactory = addToPortfolioManagerFactory,
forYouFeatureToggles = forYouFeatureToggles,
getTopFiveMarketTokenUseCase = getTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
paramsContainer = paramsContainer,
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class `initialState forYouBannerUM` {
@Test
fun `GIVEN isForYouEnabled is true WHEN model initialises THEN forYouBannerUM is Content`() = runTest {
// Arrange
val toggles = mockk<ForYouFeatureToggles> { every { isForYouEnabled } returns true }
// Act
val model = createModel(forYouFeatureToggles = toggles)
advanceUntilIdle()
// Assert
assertThat(model.state.value.forYouBannerUM).isInstanceOf(ForYouBannerUM.Content::class.java)
model.onDestroy()
}
@Test
fun `GIVEN isForYouEnabled is false WHEN model initialises THEN forYouBannerUM is Empty`() = runTest {
// Arrange
val toggles = mockk<ForYouFeatureToggles> { every { isForYouEnabled } returns false }
// Act
val model = createModel(forYouFeatureToggles = toggles)
advanceUntilIdle()
// Assert
assertThat(model.state.value.forYouBannerUM).isEqualTo(ForYouBannerUM.Empty)
model.onDestroy()
}
@Test
fun `GIVEN isForYouEnabled is true WHEN Content banner clicked THEN openForYou invoked`() = runTest {
// Arrange
val toggles = mockk<ForYouFeatureToggles> { every { isForYouEnabled } returns true }
// Act
val model = createModel(forYouFeatureToggles = toggles)
advanceUntilIdle()
val banner = model.state.value.forYouBannerUM
(banner as? ForYouBannerUM.Content)?.banner?.onClick?.invoke()
// Assert onClick must be wired to feedClickIntents::openForYou, not just any lambda
assertThat(banner).isInstanceOf(ForYouBannerUM.Content::class.java)
verify(exactly = 1) { feedClickIntents.openForYou() }
model.onDestroy()
}
}
}

View file

@ -23,6 +23,7 @@ dependencies {
implementation(deps.compose.ui) implementation(deps.compose.ui)
implementation(deps.compose.foundation) implementation(deps.compose.foundation)
implementation(deps.lifecycle.compose) implementation(deps.lifecycle.compose)
implementation(deps.compose.material3)
/** DI */ /** DI */
implementation(deps.hilt.android) implementation(deps.hilt.android)

View file

@ -1,11 +1,27 @@
package com.tangem.features.foryou.impl package com.tangem.features.foryou.impl
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.AppComponentContext
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.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.foryou.ForYouComponent import com.tangem.features.foryou.ForYouComponent
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@ -18,7 +34,26 @@ internal class DefaultForYouComponent @AssistedInject constructor(
@Composable @Composable
override fun Title(bottomSheetState: State<BottomSheetState>) { override fun Title(bottomSheetState: State<BottomSheetState>) {
TODO("Not yet implemented") TangemTopBar(
title = resourceReference(R.string.for_you_title),
type = TangemTopBarType.BottomSheet,
startContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.hazeEffectTangem { blurRadius = 8.dp }
.clickableSingle(
onClick = router::pop,
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
.padding(8.dp),
)
},
)
} }
@Composable @Composable
@ -27,7 +62,7 @@ internal class DefaultForYouComponent @AssistedInject constructor(
contentPadding: PaddingValues, contentPadding: PaddingValues,
modifier: Modifier, modifier: Modifier,
) { ) {
TODO("Not yet implemented") Text("FOR YOU")
} }
@AssistedFactory @AssistedFactory