Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-06 07:35:28 +01:00
parent 27921d8c3a
commit 8ba59403f4
20 changed files with 463 additions and 97 deletions

View file

@ -207,7 +207,7 @@ interface TangemTechApi {
suspend fun transactionEvents(@Body name: TransactionEventBody): ApiResponse<Unit>
// region Earn
@GET("v1/earn/yield/markets")
@GET("v1/earn/markets")
suspend fun getEarnTokens(
@Query("isForEarn") isForEarn: Boolean?,
@Query("page") page: String? = null,

View file

@ -14,7 +14,7 @@ internal class DefaultEarnErrorResolver : EarnErrorResolver {
message = throwable.message.orEmpty(),
)
}
else -> EarnError.NotHttpError
else -> EarnError.NotHttpError()
}
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.data.earn.converter
import com.tangem.datasource.api.tangemTech.models.EarnResponse
import com.tangem.domain.models.earn.EarnRewardType
import com.tangem.domain.models.earn.EarnToken
import com.tangem.domain.models.earn.EarnType
import com.tangem.utils.converter.Converter
internal object EarnTokenConverter : Converter<EarnResponse, EarnToken> {
@ -10,12 +12,23 @@ internal object EarnTokenConverter : Converter<EarnResponse, EarnToken> {
return EarnToken(
apy = value.apy,
networkId = value.networkId,
rewardType = value.rewardType,
type = value.type,
rewardType = convertEarnRewardType(value.rewardType),
type = convertEarnType(value.type),
tokenId = value.token.id,
tokenSymbol = value.token.symbol,
tokenName = value.token.name,
tokenAddress = value.token.address,
decimalCount = value.token.decimalCount,
)
}
private fun convertEarnRewardType(type: String): EarnRewardType {
return enumValues<EarnRewardType>().find { it.name.equals(type, ignoreCase = true) }
?: EarnRewardType.APY
}
private fun convertEarnType(type: String): EarnType {
return enumValues<EarnType>().find { it.name.equals(type, ignoreCase = true) }
?: EarnType.STAKING
}
}

View file

@ -1,11 +1,16 @@
package com.tangem.domain.models.earn
import java.util.UUID
sealed interface EarnError {
data class HttpError(
val id: String = UUID.randomUUID().toString(),
val code: Int,
val message: String,
) : EarnError
data object NotHttpError : EarnError
data class NotHttpError(
val id: String = UUID.randomUUID().toString(),
) : EarnError
}

View file

@ -17,10 +17,19 @@ import kotlinx.serialization.Serializable
data class EarnToken(
val apy: String,
val networkId: String,
val rewardType: String,
val type: String,
val rewardType: EarnRewardType,
val type: EarnType,
val tokenId: String,
val tokenSymbol: String,
val tokenName: String,
val tokenAddress: String,
)
val decimalCount: Int?,
)
enum class EarnRewardType {
APR, APY
}
enum class EarnType {
STAKING, YIELD
}

View file

@ -54,6 +54,7 @@ dependencies {
implementation(projects.domain.news)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.earn)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.

View file

@ -22,6 +22,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
@ -131,6 +132,14 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
override fun onOpenAllNews() {
innerRouter.push(FeedEntryChildFactory.Child.NewsList)
}
override fun onOpenEarnPage() {
innerRouter.push(
FeedEntryChildFactory.Child.Earn(
params = DefaultEarnComponent.Params(onBackClick = { onChildBack() }),
),
)
}
}
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>> =

View file

@ -10,6 +10,7 @@ import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
@ -21,6 +22,7 @@ internal class FeedEntryChildFactory @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory,
) {
@Serializable
@ -100,6 +102,7 @@ internal class FeedEntryChildFactory @Inject constructor(
DefaultFeedComponent(
appComponentContext = appComponentContext,
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
)
}
is Child.Earn -> {

View file

@ -6,10 +6,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleStartEffect
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.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.feed.FeedList
@ -18,10 +24,18 @@ import com.tangem.features.feed.ui.feed.FeedListHeader
internal class DefaultFeedComponent(
appComponentContext: AppComponentContext,
private val params: FeedParams,
private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val feedComponentModel = getOrCreateModel<FeedComponentModel, FeedParams>(params = params)
private val bottomSheetSlot = childSlot(
source = feedComponentModel.bottomSheetNavigation,
serializer = FeedPortfolioRoute.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by feedComponentModel.state.collectAsStateWithLifecycle()
@ -40,11 +54,28 @@ internal class DefaultFeedComponent(
}
}
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by feedComponentModel.state.collectAsStateWithLifecycle()
FeedList(
modifier = modifier,
state = state,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: FeedPortfolioRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is FeedPortfolioRoute.AddToPortfolio -> {
addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioPreselectedDataComponent.Params(
tokenToAdd = config.tokenToAdd,
callback = feedComponentModel.addToPortfolioCallback,
),
)
}
}
data class FeedParams(val feedClickIntents: FeedModelClickIntents)

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.components.feed
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import kotlinx.serialization.Serializable
@Serializable
sealed interface FeedPortfolioRoute {
@Serializable
data class AddToPortfolio(val tokenToAdd: AddToPortfolioPreselectedDataComponent.TokenToAdd) : FeedPortfolioRoute
}

View file

@ -2,7 +2,9 @@ package com.tangem.features.feed.components.market.details.portfolio.add.impl.di
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager
import dagger.Binds
import dagger.Module
@ -18,4 +20,9 @@ internal interface AddToPortfolioComponentModule {
@Binds
fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory
@Binds
fun bindAddToPortfolioPreselectedDataComponent(
factory: DefaultAddToPortfolioPreselectedDataComponent.Factory,
): AddToPortfolioPreselectedDataComponent.Factory
}

View file

@ -2,6 +2,11 @@ package com.tangem.features.feed.model.feed
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
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.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -12,20 +17,28 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.DateTimeFormatters
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.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.FeedPortfolioRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager
import com.tangem.features.feed.model.feed.state.FeedStateController
import com.tangem.features.feed.model.feed.state.transformers.UpdateGlobalFeedStateTransformer
import com.tangem.features.feed.model.feed.state.transformers.UpdateMarketChartsTransformer
import com.tangem.features.feed.model.feed.state.transformers.UpdateTrendingNewsStateTransformer
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.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -41,22 +54,26 @@ import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class FeedComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase,
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val stateController: FeedStateController,
private val feedFeatureToggle: FeedFeatureToggle,
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
private val appRouter: AppRouter,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>()
private var quotesUpdateJob: Job? = null
private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>()
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
@ -72,7 +89,22 @@ internal class FeedComponentModel @Inject constructor(
dispatchers = dispatchers,
)
internal val state: StateFlow<FeedListUM>
val bottomSheetNavigation: SlotNavigation<FeedPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioPreselectedDataComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) {
bottomSheetNavigation.dismiss()
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = walletId,
currency = addedToken,
),
)
}
}
val state: StateFlow<FeedListUM>
get() = stateController.uiState
val isVisibleOnScreen = MutableStateFlow(false)
@ -81,29 +113,38 @@ internal class FeedComponentModel @Inject constructor(
initializeState()
updateCallbacks()
fetchTrendingNews()
fetchEarnData()
fetchCharts()
subscribeOnCurrencyUpdate()
loadCharts()
subscribeOnDataState()
}
private fun subscribeOnDataState() {
modelScope.launch(dispatchers.default) {
combine(
flow = marketsBatchFlowManager.itemsByOrder,
flow2 = marketsBatchFlowManager.loadingStatesByOrder,
flow3 = marketsBatchFlowManager.errorStatesByOrder,
flow4 = manageTrendingNewsUseCase.observeTrendingNews(),
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult ->
flow5 = getTopEarnTokensUseCase(),
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult, earnResult ->
val globalStateTransformer = UpdateGlobalFeedStateTransformer(
loadingStatesByOrder = loadingStatesByOrder,
errorStatesByOrder = errorStatesByOrder,
trendingNewsResult = trendingNewsResult,
earnResult = earnResult,
onRetryClicked = {
modelScope.launch(dispatchers.default) {
stateController.update { currentState ->
currentState.copy(globalState = GlobalFeedState.Loading)
}
fetchTrendingNewsUseCase.invoke()
fetchTrendingNews()
fetchEarnData()
marketsBatchFlowManager.reloadAll()
}
},
analyticsEventHandler = analyticsEventHandler,
feedFeatureToggle = feedFeatureToggle,
)
val currentState = stateController.value
@ -123,13 +164,15 @@ internal class FeedComponentModel @Inject constructor(
),
UpdateTrendingNewsStateTransformer(
result = trendingNewsResult,
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
}
},
onRetryClicked = ::fetchTrendingNews,
analyticsEventHandler = analyticsEventHandler,
),
UpdateEarnStateTransformer(
isEarnEnabled = feedFeatureToggle.isEarnBlockEnabled,
onItemClick = ::handleEarnTokenClick,
onRetryClick = ::fetchEarnData,
earnResult = earnResult,
),
)
}
@ -144,12 +187,6 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun fetchTrendingNews() {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase()
}
}
private fun subscribeOnCurrencyUpdate() {
modelScope.launch(dispatchers.default) {
currentAppCurrency.drop(1).collect {
@ -158,7 +195,21 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun loadCharts() {
private fun fetchTrendingNews() {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase()
}
}
private fun fetchEarnData() {
if (!feedFeatureToggle.isEarnBlockEnabled) return
modelScope.launch(dispatchers.default) {
stateController.update(UpdateEarnLoadingStateTransformer())
fetchTopEarnTokensUseCase()
}
}
private fun fetchCharts() {
modelScope.launch(dispatchers.default) {
TokenMarketListConfig.Order.entries.forEach { order ->
marketsBatchFlowManager.getOnLastBatchLoadedSuccessFlow(order)?.collect { batchKey ->
@ -198,6 +249,7 @@ internal class FeedComponentModel @Inject constructor(
onSliderEndReached = {
analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselEndReached())
},
onOpenEarnPageClick = params.feedClickIntents::onOpenEarnPage,
),
news = NewsUM(
content = persistentListOf(),
@ -214,14 +266,41 @@ internal class FeedComponentModel @Inject constructor(
currentSortByType = SortByTypeUM.TopGainers,
),
globalState = GlobalFeedState.Loading,
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
null
},
)
}
private fun getCurrentDate(): String {
val localDate = DateTime(DateTime.now(), DateTimeZone.getDefault())
return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate)
private fun startQuotesUpdateTimer() {
quotesUpdateJob?.cancel()
quotesUpdateJob = modelScope.launch {
while (true) {
delay(DELAY_TO_FETCH_QUOTES)
isVisibleOnScreen.first { it }
marketsBatchFlowManager.updateQuotes()
}
}
}
private fun updateCallbacks() {
stateController.update { feedListUM ->
feedListUM.copy(
feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::handleSortTypeClicked,
onMarketItemClick = ::handleMarketItemClicked,
onMarketOpenClick = ::handleMarketOpenClicked,
onArticleClick = ::handleArticleClicked,
onOpenAllNews = ::handleOpenAllNews,
onOpenEarnPageClick = params.feedClickIntents::onOpenEarnPage,
),
)
}
}
/* start of clicks area */
private fun handleSortTypeClicked(sortByType: SortByTypeUM) {
stateController.update { currentState ->
val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) ->
@ -249,31 +328,6 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun startQuotesUpdateTimer() {
quotesUpdateJob?.cancel()
quotesUpdateJob = modelScope.launch {
while (true) {
delay(DELAY_TO_FETCH_QUOTES)
isVisibleOnScreen.first { it }
marketsBatchFlowManager.updateQuotes()
}
}
}
private fun updateCallbacks() {
stateController.update { feedListUM ->
feedListUM.copy(
feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::handleSortTypeClicked,
onMarketItemClick = ::handleMarketItemClicked,
onMarketOpenClick = ::handleMarketOpenClicked,
onArticleClick = ::handleArticleClicked,
onOpenAllNews = ::handleOpenAllNews,
),
)
}
}
private fun handleMarketItemClicked(item: MarketsListItemUM) {
val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id)
if (tokenMarket != null) {
@ -342,6 +396,26 @@ internal class FeedComponentModel @Inject constructor(
params.feedClickIntents.onOpenAllNews()
}
private fun handleEarnTokenClick(earnTokenWithCurrency: EarnTokenWithCurrency) {
bottomSheetNavigation.activate(
FeedPortfolioRoute.AddToPortfolio(
tokenToAdd = AddToPortfolioPreselectedDataComponent.TokenToAdd(
network = TokenMarketInfo.Network(
networkId = earnTokenWithCurrency.earnToken.networkId,
isExchangeable = false,
contractAddress = earnTokenWithCurrency.earnToken.tokenAddress,
decimalCount = earnTokenWithCurrency.earnToken.decimalCount,
),
id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId),
name = earnTokenWithCurrency.earnToken.tokenName,
symbol = earnTokenWithCurrency.earnToken.tokenSymbol,
),
),
)
}
/* end of clicks area */
/* start of utils area */
private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order {
return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating
@ -354,6 +428,12 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun getCurrentDate(): String {
val localDate = DateTime(DateTime.now(), DateTimeZone.getDefault())
return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate)
}
/* end of utils area */
companion object {
private const val DELAY_TO_FETCH_QUOTES = 60_000L
}

View file

@ -27,4 +27,6 @@ internal interface FeedModelClickIntents {
paginationConfig: NewsListConfig? = null,
)
fun onOpenAllNews()
fun onOpenEarnPage()
}

View file

@ -1,8 +1,11 @@
package com.tangem.features.feed.model.feed.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer
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 kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.collections.immutable.persistentListOf
@ -13,7 +16,9 @@ import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class FeedStateController @Inject constructor() {
internal class FeedStateController @Inject constructor(
private val feedFeatureToggle: FeedFeatureToggle,
) {
private val mutableUiState: MutableStateFlow<FeedListUM> = MutableStateFlow(value = getInitialState())
@ -33,15 +38,11 @@ internal class FeedStateController @Inject constructor() {
transformers.forEach { mutableUiState.update(function = it::transform) }
}
fun clear() {
mutableUiState.update { getInitialState() }
}
private fun getInitialState(): FeedListUM {
return FeedListUM(
currentDate = "",
feedListSearchBar = FeedListSearchBar(
placeholderText = com.tangem.core.ui.extensions.TextReference.EMPTY,
placeholderText = TextReference.EMPTY,
onBarClick = {},
),
feedListCallbacks = FeedListCallbacks(
@ -53,6 +54,7 @@ internal class FeedStateController @Inject constructor() {
onSortTypeClick = {},
onSliderScroll = {},
onSliderEndReached = {},
onOpenEarnPageClick = {},
),
news = NewsUM(
content = persistentListOf(),
@ -65,6 +67,11 @@ internal class FeedStateController @Inject constructor() {
currentSortByType = SortByTypeUM.Trending,
),
globalState = GlobalFeedState.Loading,
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
null
},
)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.FeedListUM
internal class UpdateEarnLoadingStateTransformer : FeedListUMTransformer {
override fun transform(prevState: FeedListUM): FeedListUM {
return prevState.copy(earnListUM = EarnListUM.Loading)
}
}

View file

@ -0,0 +1,111 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.models.earn.EarnRewardType
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.earn.EarnType
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class UpdateEarnStateTransformer(
private val isEarnEnabled: Boolean,
private val onItemClick: (EarnTokenWithCurrency) -> Unit,
private val onRetryClick: () -> Unit,
private val earnResult: EarnTopToken?,
) : FeedListUMTransformer {
private val earnTokenWithCurrencyConverter = EarnTokenWithCurrencyConverter(onItemClick = onItemClick)
override fun transform(prevState: FeedListUM): FeedListUM {
if (!isEarnEnabled) return handleEmptyState(prevState)
return when (earnResult) {
null -> {
handleEmptyState(prevState)
}
else -> {
earnResult.fold(
ifLeft = {
handleErrorState(prevState)
},
ifRight = { earnTokensWithCurrency ->
handleDataState(
currentState = prevState,
earnTokensWithCurrency = earnTokensWithCurrency,
)
},
)
}
}
}
private fun handleEmptyState(currentState: FeedListUM): FeedListUM {
return currentState.copy(earnListUM = null)
}
private fun handleErrorState(currentState: FeedListUM): FeedListUM {
return currentState.copy(earnListUM = EarnListUM.Error(onRetryClick))
}
private fun handleDataState(
currentState: FeedListUM,
earnTokensWithCurrency: List<EarnTokenWithCurrency>,
): FeedListUM {
return currentState.copy(
earnListUM = EarnListUM.Content(
items = earnTokensWithCurrency
.sortedWith(
compareByDescending<EarnTokenWithCurrency> {
it.earnToken.apy.toBigDecimalOrNull() ?: BigDecimal.ZERO
}.thenBy { it.earnToken.tokenName },
)
.map { earnToken ->
earnTokenWithCurrencyConverter.convert(earnToken)
}
.toPersistentList(),
),
)
}
private class EarnTokenWithCurrencyConverter(
private val onItemClick: (EarnTokenWithCurrency) -> Unit,
) : Converter<EarnTokenWithCurrency, EarnListItemUM> {
override fun convert(value: EarnTokenWithCurrency): EarnListItemUM {
return EarnListItemUM(
network = TextReference.Str(value.networkName),
symbol = TextReference.Str(value.earnToken.tokenSymbol),
tokenName = TextReference.Str(value.earnToken.tokenName),
currencyIconState = CryptoCurrencyToIconStateConverter().convert(value.cryptoCurrency),
earnValue = when (value.earnToken.rewardType) {
EarnRewardType.APR -> TextReference.Res(
id = R.string.staking_apr_earn_badge,
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
EarnRewardType.APY -> TextReference.Res(
id = R.string.yield_module_earn_badge,
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
},
earnType = when (value.earnToken.type) {
EarnType.STAKING -> TextReference.Res(R.string.common_staking)
EarnType.YIELD -> TextReference.Res(R.string.common_yield_mode)
},
onItemClick = { onItemClick(value) },
)
}
private fun convertPercent(value: String): TextReference {
val percent = BigDecimal(value).format { percent(withPercentSign = false) }
return TextReference.Str(percent)
}
}
}

View file

@ -1,57 +1,58 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
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.FeedListUM
import com.tangem.features.feed.ui.feed.state.GlobalFeedState
import com.tangem.features.feed.ui.feed.state.NewsUMState
@Suppress("LongParameterList")
internal class UpdateGlobalFeedStateTransformer(
private val loadingStatesByOrder: Map<SortByTypeUM, Boolean>,
private val errorStatesByOrder: Map<SortByTypeUM, Throwable?>,
private val trendingNewsResult: TrendingNews,
private val earnResult: EarnTopToken?,
private val onRetryClicked: () -> Unit,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feedFeatureToggle: FeedFeatureToggle,
) : FeedListUMTransformer {
@Suppress("CyclomaticComplexMethod")
override fun transform(prevState: FeedListUM): FeedListUM {
val previousGlobalState = prevState.globalState
val blockStates = buildList {
add(getNewsState(prevState))
add(getChartsState())
if (feedFeatureToggle.isEarnBlockEnabled) {
add(getEarnState(prevState))
}
}
val isNewsLoading = prevState.news.newsUMState == NewsUMState.LOADING
val isNewsError = trendingNewsResult is TrendingNews.Error ||
prevState.news.newsUMState == NewsUMState.ERROR
val newGlobalState = when {
blockStates.all { it == BlockState.ERROR } -> {
if (prevState.globalState !is GlobalFeedState.Error) {
sendErrorAnalytics()
}
GlobalFeedState.Error(onRetryClicked = onRetryClicked)
}
blockStates.any { it == BlockState.LOADING } -> GlobalFeedState.Loading
else -> GlobalFeedState.Content
}
return prevState.copy(globalState = newGlobalState)
}
private fun areAllChartsError(): Boolean {
val hasLoadingInCharts = loadingStatesByOrder.values.any { it }
val hasErrorInMarketChart = errorStatesByOrder[SortByTypeUM.Rating] != null
val hasErrorInMarketPulseChart = errorStatesByOrder
.any { it.key != SortByTypeUM.Rating && it.value != null }
val areAllChartsLoading = loadingStatesByOrder.values.isNotEmpty() && loadingStatesByOrder.values.all { it }
val areAllChartsError = when {
hasErrorInMarketChart && hasErrorInMarketPulseChart -> true
hasErrorInMarketPulseChart && hasLoadingInCharts -> true
hasErrorInMarketChart && hasLoadingInCharts -> true
else -> false
}
val newGlobalState = when {
isNewsError && areAllChartsLoading -> GlobalFeedState.Loading
isNewsError && areAllChartsError -> {
if (previousGlobalState !is GlobalFeedState.Error) {
sendErrorAnalytics()
}
GlobalFeedState.Error(
onRetryClicked = onRetryClicked,
)
}
isNewsLoading && areAllChartsError -> GlobalFeedState.Loading
isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading
else -> GlobalFeedState.Content
}
return prevState.copy(globalState = newGlobalState)
return hasErrorInMarketChart && hasErrorInMarketPulseChart ||
hasErrorInMarketPulseChart && hasLoadingInCharts ||
hasErrorInMarketChart && hasLoadingInCharts
}
private fun sendErrorAnalytics() {
@ -59,4 +60,38 @@ internal class UpdateGlobalFeedStateTransformer(
FeedAnalyticsEvent.AllWidgetsLoadError(),
)
}
private fun getNewsState(prevState: FeedListUM): BlockState {
val isError = trendingNewsResult is TrendingNews.Error || prevState.news.newsUMState == NewsUMState.ERROR
if (isError) return BlockState.ERROR
val isLoading = prevState.news.newsUMState == NewsUMState.LOADING && trendingNewsResult !is TrendingNews.Data
if (isLoading) return BlockState.LOADING
return BlockState.CONTENT
}
private fun getChartsState(): BlockState {
if (areAllChartsError()) return BlockState.ERROR
val isLoading = loadingStatesByOrder.values.isNotEmpty() && loadingStatesByOrder.values.all { it }
if (isLoading) return BlockState.LOADING
return BlockState.CONTENT
}
private fun getEarnState(prevState: FeedListUM): BlockState {
val isError = when (earnResult) {
null -> false
else -> earnResult.isLeft()
}
if (isError) return BlockState.ERROR
val isLoading = prevState.earnListUM is EarnListUM.Loading && earnResult == null
if (isLoading) return BlockState.LOADING
return BlockState.CONTENT
}
private enum class BlockState { LOADING, ERROR, CONTENT }
}

View file

@ -147,10 +147,8 @@ private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
)
EarnBlock(
onSeeAllClick = {
// TODO handle in [REDACTED_TASK_KEY]
},
earnListUM = null, // TODO in [REDACTED_TASK_KEY]
onSeeAllClick = state.feedListCallbacks.onOpenEarnPageClick,
earnListUM = state.earnListUM,
)
MarketPulseBlock(

View file

@ -3,13 +3,18 @@ package com.tangem.features.feed.ui.feed.preview
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.R
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.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.*
import kotlinx.collections.immutable.*
@ -34,6 +39,7 @@ internal object FeedListPreviewDataProvider {
onSortTypeClick = {},
onSliderScroll = {},
onSliderEndReached = {},
onOpenEarnPageClick = {},
),
news = NewsUM(
content = articles.filter { it.isTrending.not() }.toImmutableList(),
@ -45,6 +51,9 @@ internal object FeedListPreviewDataProvider {
marketCharts = createMarketCharts(marketItems, includeErrorState = false),
currentSortByType = SortByTypeUM.TopGainers,
),
earnListUM = EarnListUM.Content(
items = createEarnListItemsUM(),
),
)
}
@ -250,4 +259,25 @@ internal object FeedListPreviewDataProvider {
updateTimestamp = 0,
)
}
private fun createEarnListItemsUM(): ImmutableList<EarnListItemUM> {
return List(5) {
EarnListItemUM(
network = stringReference("Ethereum"),
symbol = stringReference("USDT"),
tokenName = stringReference("TETHER"),
currencyIconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
onItemClick = {},
)
}.toPersistentList()
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
@ -17,6 +18,7 @@ internal data class FeedListUM(
val trendingArticle: ArticleConfigUM?,
val marketChartConfig: MarketChartConfig,
val globalState: GlobalFeedState = GlobalFeedState.Content,
val earnListUM: EarnListUM?,
)
internal data class FeedListCallbacks(
@ -28,6 +30,7 @@ internal data class FeedListCallbacks(
val onSortTypeClick: (sortBy: SortByTypeUM) -> Unit,
val onSliderScroll: () -> Unit,
val onSliderEndReached: () -> Unit,
val onOpenEarnPageClick: () -> Unit,
)
internal data class FeedListSearchBar(