Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 13:39:30 +03:00
commit 92849d43c2
1407 changed files with 64092 additions and 11990 deletions

View file

@ -11,19 +11,20 @@ android {
}
dependencies {
implementation(deps.compose.foundation)
/* Project - API */
api(projects.features.account.api)
/* Kotlin */
api(deps.kotlin.coroutines)
api(deps.kotlin.serialization.core)
/* Compose */
api(deps.compose.foundation)
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
api(projects.core.decompose)
api(projects.core.ui)
/* Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.markets.models)
api(projects.domain.appCurrency.models)
api(projects.domain.markets.models)
api(projects.domain.models)
}

View file

@ -18,51 +18,48 @@ android {
}
dependencies {
/* Project - API */
api(projects.features.feed.api)
api(projects.features.onramp.api)
api(projects.features.send.api)
api(projects.features.tokenRecieve.api)
api(projects.features.wallet.api)
api(projects.features.account.api)
api(projects.features.commonFeatures.api)
implementation(projects.features.promoBanners.api)
api(projects.features.feed.api)
api(projects.features.forYou.api)
api(projects.features.promoBanners.api)
api(projects.features.tokenRecieve.api)
/* Data */
implementation(projects.data.common)
/* Domain */
api(projects.domain.account.status)
api(projects.domain.appCurrency)
api(projects.domain.balanceHiding)
api(projects.domain.common)
api(projects.domain.earn)
api(projects.domain.feedback)
api(projects.domain.markets)
api(projects.domain.models)
api(projects.domain.news)
api(projects.domain.search)
api(projects.domain.settings)
api(projects.domain.tokens)
api(projects.domain.transaction)
api(projects.domain.wallets)
api(projects.domain.yieldSupply)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.card)
implementation(projects.domain.demo)
implementation(projects.domain.feedback)
implementation(projects.domain.core)
implementation(projects.domain.feedback.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.settings)
implementation(projects.domain.notifications.models)
implementation(projects.domain.transaction)
implementation(projects.domain.news)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.earn)
implementation(projects.domain.search)
runtimeOnly(projects.domain.manageTokens)
runtimeOnly(projects.domain.offramp)
/* Domain models */
api(projects.domain.markets.models)
/* Compose */
implementation(deps.compose.coil)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
api(deps.compose.coil)
api(deps.compose.foundation)
api(deps.compose.material3)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
@ -75,31 +72,38 @@ dependencies {
kapt(deps.hilt.kapt)
/* Other */
implementation(deps.kotlin.immutable.collections)
implementation(deps.decompose.ext.compose)
api(deps.decompose.ext.compose)
api(deps.kotlin.coroutines)
api(deps.kotlin.immutable.collections)
api(deps.kotlin.serialization.core)
implementation(deps.androidx.annotation)
implementation(deps.androidx.appCompat)
implementation(deps.arrow.core)
implementation(deps.haze)
implementation(deps.jodatime)
/* Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.configToggles)
implementation(projects.core.analytics)
api(projects.core.analytics)
api(projects.core.decompose)
api(projects.core.navigation)
api(projects.core.ui)
api(projects.core.utils)
implementation(projects.core.analytics.models)
implementation(projects.core.navigation)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.core.pagination)
/* Common */
implementation(projects.common.ui)
api(projects.common.routing)
api(projects.common.ui)
api(projects.common.uiMarkets)
implementation(projects.common)
implementation(projects.common.uiCharts)
implementation(projects.common.routing)
implementation(projects.common.uiMarkets)
/* Libs */
api(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(projects.libs.blockchainSdk)
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
/** Tests */
@ -107,4 +111,5 @@ dependencies {
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
testImplementation(projects.domain.wallets.models)
}

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

@ -37,6 +37,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 +62,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 +275,11 @@ internal class FeedComponentModel @Inject constructor(
),
globalState = GlobalFeedState.Loading,
earnListUM = EarnListUM.Loading,
forYouBannerUM = if (forYouFeatureToggles.isForYouEnabled) {
ForYouBannerUM.Content(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

@ -4,9 +4,11 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
@ -16,17 +18,20 @@ 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.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.*
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_pie_chart_20
import com.tangem.core.ui.test.BaseSearchBarTestTags.SEARCH_BAR
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.components.FeedSearchBar
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 +110,27 @@ private fun FeedListContent(
SpacerH(contentPadding.calculateTopPadding())
}
DateBlock(state.currentDate)
SpacerH(16.dp)
if (state.forYouBannerUM is ForYouBannerUM.Content && LocalRedesignEnabled.current) {
TangemMessageBanner(
title = resourceReference(R.string.for_you_title),
description = resourceReference(R.string.for_you_description),
variant = TangemMessageBanner.Variant.Default,
slotStart = {
Icon(
imageVector = Icons.ic_pie_chart_20,
tint = TangemTheme.colors3.icon.primary,
contentDescription = null,
)
},
modifier = Modifier
.padding(horizontal = 16.dp)
.clickable(onClick = state.forYouBannerUM.onClick),
)
}
SpacerH(32.dp)
MarketBlock(

View file

@ -56,6 +56,7 @@ internal object FeedListPreviewDataProvider {
earnListUM = EarnListUM.Content(
items = createEarnListItemsUM(),
),
forYouBannerUM = createForYouItem(),
)
}
@ -285,4 +286,10 @@ internal object FeedListPreviewDataProvider {
)
}.toPersistentList()
}
private fun createForYouItem(): ForYouBannerUM {
return ForYouBannerUM.Content(
onClick = {},
)
}
}

View file

@ -2,10 +2,10 @@ 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.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 +19,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 +81,16 @@ internal data class SortChartConfigUM(
val isSelected: Boolean,
)
@Immutable
internal sealed interface ForYouBannerUM {
data class Content(
val onClick: () -> Unit,
) : 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)?.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()
}
}
}