Updated on 2026-08-14
This commit is contained in:
parent
48137d6199
commit
aae00c8dd5
18 changed files with 308 additions and 3 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>> =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,4 +31,6 @@ internal interface FeedModelClickIntents {
|
|||
fun onOpenEarnPage()
|
||||
|
||||
fun openSearch(source: String)
|
||||
|
||||
fun openForYou()
|
||||
}
|
||||
|
|
@ -65,6 +65,7 @@ internal class FeedStateController @Inject constructor() {
|
|||
),
|
||||
globalState = GlobalFeedState.Loading,
|
||||
earnListUM = EarnListUM.Loading,
|
||||
forYouBannerUM = ForYouBannerUM.Empty,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue