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

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

View file

@ -142,6 +142,10 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
override fun openSearch(source: String) {
stackNavigation.bringToFront(FeedEntryChildFactory.Child.Search(source))
}
override fun openForYou() {
stackNavigation.bringToFront(FeedEntryChildFactory.Child.ForYou)
}
}
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.search.DefaultSearchComponent
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
@ -33,6 +34,7 @@ internal class FeedEntryChildFactory @Inject constructor(
private val manageFundsComponentFactory: ManageFundsComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val designFeatureToggles: DesignFeatureToggles,
private val forYouComponentFactory: ForYouComponent.Factory,
) {
@Serializable
@ -66,6 +68,10 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data class Search(val source: String) : Child
@Serializable
@Immutable
data object ForYou : Child
}
@Suppress("LongMethod")
@ -147,6 +153,10 @@ internal class FeedEntryChildFactory @Inject constructor(
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.ParamsContainer
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.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.domain.appcurrency.GetSelectedAppCurrencyUseCase
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.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.foryou.ForYouFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
@ -61,6 +67,7 @@ internal class FeedComponentModel @Inject constructor(
private val appRouter: AppRouter,
private val designFeatureToggles: DesignFeatureToggles,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val forYouFeatureToggles: ForYouFeatureToggles,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer,
@ -273,6 +280,20 @@ internal class FeedComponentModel @Inject constructor(
),
globalState = GlobalFeedState.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 openSearch(source: String)
fun openForYou()
}

View file

@ -65,6 +65,7 @@ internal class FeedStateController @Inject constructor() {
),
globalState = GlobalFeedState.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 com.tangem.core.ui.components.SpacerH
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.LocalRedesignEnabled
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.state.FeedListSearchBar
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
@Composable
@ -105,6 +107,17 @@ private fun FeedListContent(
SpacerH(contentPadding.calculateTopPadding())
}
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)
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.LabelUM
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.resourceReference
import com.tangem.core.ui.extensions.stringReference
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.earn.EarnType
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
@ -56,6 +62,7 @@ internal object FeedListPreviewDataProvider {
earnListUM = EarnListUM.Content(
items = createEarnListItemsUM(),
),
forYouBannerUM = createForYouItem(),
)
}
@ -285,4 +292,17 @@ internal object FeedListPreviewDataProvider {
)
}.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 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.features.feed.model.market.list.state.SortByTypeUM
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.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
@ -19,6 +20,7 @@ internal data class FeedListUM(
val marketChartConfig: MarketChartConfig,
val globalState: GlobalFeedState = GlobalFeedState.Content,
val earnListUM: EarnListUM,
val forYouBannerUM: ForYouBannerUM,
)
internal data class FeedListCallbacks(
@ -80,6 +82,16 @@ internal data class SortChartConfigUM(
val isSelected: Boolean,
)
@Immutable
internal sealed interface ForYouBannerUM {
data class Content(
val banner: TangemMessageUM,
) : ForYouBannerUM
data object Empty : ForYouBannerUM
}
@Immutable
internal sealed interface 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()
}
}
}