Updated on 2026-08-14
This commit is contained in:
parent
c2e67ba6bc
commit
a02b13ae6a
19 changed files with 544 additions and 273 deletions
|
|
@ -1,39 +0,0 @@
|
|||
package com.tangem.common.ui.markets
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.buttons.SecondarySmallButton
|
||||
import com.tangem.core.ui.components.buttons.SmallButtonConfig
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Composable
|
||||
fun MarketChartLoadingError(onRetryClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.padding(vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.common_unable_to_load),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
SpacerH(12.dp)
|
||||
SecondarySmallButton(
|
||||
config = SmallButtonConfig(
|
||||
text = TextReference.Res(R.string.try_to_load_data_again_button_title),
|
||||
onClick = onRetryClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,25 @@
|
|||
package com.tangem.datasource.local.news.trending
|
||||
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.models.news.TrendingNews
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private typealias TrendingCache = Map<String, List<ShortArticle>>
|
||||
private typealias TrendingCache = Map<String, TrendingNews>
|
||||
|
||||
internal class DefaultTrendingNewsStore(
|
||||
private val store: RuntimeSharedStore<TrendingCache>,
|
||||
) : TrendingNewsStore {
|
||||
|
||||
override fun get(key: String): Flow<List<ShortArticle>> {
|
||||
return store.get().map { it[key].orEmpty() }
|
||||
override fun get(key: String): Flow<TrendingNews> {
|
||||
return store.get().map { it[key] ?: TrendingNews.Data(emptyList()) }
|
||||
}
|
||||
|
||||
override suspend fun getSyncOrNull(key: String): List<ShortArticle>? {
|
||||
override suspend fun getSyncOrNull(key: String): TrendingNews? {
|
||||
return store.getSyncOrNull()?.get(key)
|
||||
}
|
||||
|
||||
override suspend fun store(key: String, value: List<ShortArticle>) {
|
||||
override suspend fun store(key: String, value: TrendingNews) {
|
||||
store.update(emptyMap()) { current ->
|
||||
current + (key to value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
package com.tangem.datasource.local.news.trending
|
||||
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.models.news.TrendingNews
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface TrendingNewsStore {
|
||||
|
||||
fun get(key: String): Flow<List<ShortArticle>>
|
||||
fun get(key: String): Flow<TrendingNews>
|
||||
|
||||
suspend fun getSyncOrNull(key: String): List<ShortArticle>?
|
||||
suspend fun getSyncOrNull(key: String): TrendingNews?
|
||||
|
||||
suspend fun store(key: String, value: List<ShortArticle>)
|
||||
suspend fun store(key: String, value: TrendingNews)
|
||||
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
package com.tangem.data.news.repository
|
||||
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.news.NewsApi
|
||||
import com.tangem.datasource.api.news.models.response.NewsTrendingResponse
|
||||
import com.tangem.datasource.local.news.details.NewsDetailsStore
|
||||
import com.tangem.datasource.local.news.trending.TrendingNewsStore
|
||||
import com.tangem.domain.models.news.*
|
||||
import com.tangem.domain.news.model.NewsListBatchFlow
|
||||
import com.tangem.domain.news.model.NewsListBatchingContext
|
||||
import com.tangem.domain.news.model.NewsListConfig
|
||||
import com.tangem.domain.models.news.ArticleCategory
|
||||
import com.tangem.domain.models.news.DetailedArticle
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.news.repository.NewsRepository
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
import com.tangem.pagination.BatchListSource
|
||||
|
|
@ -24,7 +25,7 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.collections.orEmpty
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Implementation of [NewsRepository].
|
||||
|
|
@ -67,26 +68,26 @@ internal class DefaultNewsRepository(
|
|||
fetchDetailedArticlesInternal(newsIds = newsIds, language = language)
|
||||
}
|
||||
|
||||
override suspend fun getTrendingNews(limit: Int, language: String?) {
|
||||
override suspend fun fetchTrendingNews(limit: Int, language: String?) {
|
||||
fetchAndStoreTrendingNews(limit = limit, language = language)
|
||||
}
|
||||
|
||||
override fun observeTrendingNews(): Flow<List<ShortArticle>> {
|
||||
override fun observeTrendingNews(): Flow<TrendingNews> {
|
||||
return trendingNewsStore.get(TRENDING_NEWS_KEY)
|
||||
}
|
||||
|
||||
override suspend fun refreshTrendingNews(limit: Int, language: String?) {
|
||||
fetchAndStoreTrendingNews(limit = limit, language = language)
|
||||
}
|
||||
|
||||
override suspend fun updateTrendingNewsViewed(articleIds: Collection<Int>, viewed: Boolean) {
|
||||
if (articleIds.isEmpty()) return
|
||||
|
||||
val current = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty()
|
||||
if (current.isEmpty()) return
|
||||
val currentResult = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) ?: return
|
||||
val currentArticles = when (currentResult) {
|
||||
is TrendingNews.Data -> currentResult.articles
|
||||
is TrendingNews.Error -> return
|
||||
}
|
||||
if (currentArticles.isEmpty()) return
|
||||
|
||||
val ids = articleIds.toSet()
|
||||
val updated = current.map { article ->
|
||||
val updated = currentArticles.map { article ->
|
||||
if (article.id in ids) {
|
||||
article.copy(viewed = viewed)
|
||||
} else {
|
||||
|
|
@ -94,7 +95,7 @@ internal class DefaultNewsRepository(
|
|||
}
|
||||
}
|
||||
|
||||
trendingNewsStore.store(TRENDING_NEWS_KEY, updated)
|
||||
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(updated))
|
||||
}
|
||||
|
||||
override suspend fun getCategories(): List<ArticleCategory> {
|
||||
|
|
@ -137,16 +138,46 @@ internal class DefaultNewsRepository(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?): List<ShortArticle> {
|
||||
private suspend fun fetchAndStoreTrendingNews(limit: Int, language: String?) {
|
||||
return withContext(dispatchers.io) {
|
||||
val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow()
|
||||
val freshArticles = response.items.map { it.toDomainShortArticle() }
|
||||
val currentArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY).orEmpty()
|
||||
val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit)
|
||||
|
||||
trendingNewsStore.store(TRENDING_NEWS_KEY, merged)
|
||||
|
||||
merged
|
||||
val apiResponse = newsApi.getTrendingNews(limit = limit, language = language)
|
||||
when (val result = apiResponse) {
|
||||
is ApiResponse.Error -> {
|
||||
Timber.e(
|
||||
result.cause.cause,
|
||||
"Trending news fetch failed cause: ${
|
||||
when (val error = result.cause) {
|
||||
is ApiResponseError.HttpException -> error.code
|
||||
is ApiResponseError.NetworkException -> "NetworkException"
|
||||
is ApiResponseError.TimeoutException -> "TimeoutException"
|
||||
is ApiResponseError.UnknownException -> "UnknownException"
|
||||
}
|
||||
}",
|
||||
)
|
||||
trendingNewsStore.clear()
|
||||
trendingNewsStore.store(
|
||||
key = TRENDING_NEWS_KEY,
|
||||
value = TrendingNews.Error(
|
||||
NewsError.Unknown(
|
||||
message = result.cause.message,
|
||||
code = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
is ApiResponse.Success<NewsTrendingResponse> -> {
|
||||
val freshArticles = result.data.items.map { it.toDomainShortArticle() }
|
||||
val cachedArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY)
|
||||
val currentArticles = when (cachedArticles) {
|
||||
is TrendingNews.Data -> cachedArticles.articles
|
||||
is TrendingNews.Error -> emptyList()
|
||||
null -> emptyList()
|
||||
}
|
||||
val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit)
|
||||
trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(merged))
|
||||
TrendingNews.Data(merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.domain.models.news
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class NewsError {
|
||||
abstract val message: String?
|
||||
abstract val code: Int?
|
||||
|
||||
data class ArticleNotFound(
|
||||
override val message: String?,
|
||||
override val code: Int?,
|
||||
) : NewsError()
|
||||
|
||||
data class Unknown(
|
||||
override val message: String?,
|
||||
override val code: Int?,
|
||||
) : NewsError()
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.domain.models.news
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the result of fetching news.
|
||||
* Can contain either data or an error.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface TrendingNews {
|
||||
@Serializable
|
||||
data class Data(val articles: List<ShortArticle>) : TrendingNews
|
||||
|
||||
@Serializable
|
||||
data class Error(val throwable: NewsError) : TrendingNews
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
package com.tangem.domain.news.repository
|
||||
|
||||
import com.tangem.domain.news.model.NewsListBatchFlow
|
||||
import com.tangem.domain.news.model.NewsListBatchingContext
|
||||
import com.tangem.domain.models.news.ArticleCategory
|
||||
import com.tangem.domain.models.news.DetailedArticle
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.models.news.TrendingNews
|
||||
import com.tangem.domain.news.model.NewsListBatchFlow
|
||||
import com.tangem.domain.news.model.NewsListBatchingContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
|
|
@ -43,17 +43,12 @@ interface NewsRepository {
|
|||
* @param limit
|
||||
* @param language current device locale
|
||||
*/
|
||||
suspend fun getTrendingNews(limit: Int, language: String?)
|
||||
suspend fun fetchTrendingNews(limit: Int, language: String?)
|
||||
|
||||
/**
|
||||
* Observes trending news with runtime viewed flag support.
|
||||
*/
|
||||
fun observeTrendingNews(): Flow<List<ShortArticle>>
|
||||
|
||||
/**
|
||||
* Refreshes trending news list and updates cache without overriding viewed status.
|
||||
*/
|
||||
suspend fun refreshTrendingNews(limit: Int, language: String?)
|
||||
fun observeTrendingNews(): Flow<TrendingNews>
|
||||
|
||||
/**
|
||||
* Updates viewed flag for provided trending articles.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import java.util.Locale
|
|||
class FetchTrendingNewsUseCase(private val newsRepository: NewsRepository) {
|
||||
|
||||
suspend operator fun invoke(): Either<Throwable, Unit> = Either.catch {
|
||||
newsRepository.getTrendingNews(
|
||||
newsRepository.fetchTrendingNews(
|
||||
limit = LIMIT_FOR_TRENDING_NEWS,
|
||||
language = Locale.getDefault().language,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
package com.tangem.domain.news.usecase
|
||||
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.models.news.TrendingNews
|
||||
import com.tangem.domain.news.repository.NewsRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
/**
|
||||
* Exposes trending news as a cached flow and provides helpers to refresh or mark items as viewed.
|
||||
|
|
@ -12,17 +13,12 @@ import kotlinx.coroutines.flow.Flow
|
|||
class ManageTrendingNewsUseCase(private val repository: NewsRepository) {
|
||||
|
||||
/**
|
||||
* Observes the current cached list of trending articles (max 10 items).
|
||||
* Observes the current cached list of trending articles (max 10 items) or error.
|
||||
*/
|
||||
operator fun invoke(): Flow<List<ShortArticle>> {
|
||||
return repository.observeTrendingNews()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces refresh from backend while preserving local `viewed` flags.
|
||||
*/
|
||||
suspend fun refresh(limit: Int, language: String?) {
|
||||
repository.refreshTrendingNews(limit, language)
|
||||
fun observeTrendingNews(): Flow<TrendingNews> {
|
||||
return repository
|
||||
.observeTrendingNews()
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ import com.tangem.domain.appcurrency.model.AppCurrency
|
|||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
|
||||
import com.tangem.features.feed.entry.components.FeedEntryComponent
|
||||
import com.tangem.features.feed.model.feed.FeedModelClickIntents
|
||||
import com.tangem.features.feed.ui.EntryBottomSheetContent
|
||||
import com.tangem.features.feed.ui.market.state.SortByTypeUM
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -40,6 +42,36 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
|||
popCallback = { onChildBack() },
|
||||
)
|
||||
|
||||
private val clickIntents = object : FeedEntryClickIntents {
|
||||
override fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency) {
|
||||
innerRouter.push(
|
||||
route = FeedEntryChildFactory.Child.TokenDetails(
|
||||
params = DefaultMarketsTokenDetailsComponent.Params(
|
||||
token = token,
|
||||
appCurrency = appCurrency,
|
||||
shouldShowPortfolio = true,
|
||||
analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams(
|
||||
blockchain = null,
|
||||
source = "Market",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMarketOpenClick(sortBy: SortByTypeUM) {
|
||||
innerRouter.push(FeedEntryChildFactory.Child.TokenList)
|
||||
}
|
||||
|
||||
override fun onArticleClick(articleId: Int) {
|
||||
innerRouter.push(FeedEntryChildFactory.Child.NewsDetails)
|
||||
}
|
||||
|
||||
override fun onOpenAllNews() {
|
||||
innerRouter.push(FeedEntryChildFactory.Child.NewsList)
|
||||
}
|
||||
}
|
||||
|
||||
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularContentComponent>> = childStack(
|
||||
key = "main",
|
||||
source = stackNavigation,
|
||||
|
|
@ -53,7 +85,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
|||
componentContext = factoryContext,
|
||||
router = innerRouter,
|
||||
),
|
||||
onTokenClick = ::marketsListTokenSelected,
|
||||
feedEntryClickIntents = clickIntents,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -76,22 +108,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun marketsListTokenSelected(token: TokenMarketParams, appCurrency: AppCurrency) {
|
||||
innerRouter.push(
|
||||
route = FeedEntryChildFactory.Child.TokenDetails(
|
||||
params = DefaultMarketsTokenDetailsComponent.Params(
|
||||
token = token,
|
||||
appCurrency = appCurrency,
|
||||
shouldShowPortfolio = true,
|
||||
analyticsParams = DefaultMarketsTokenDetailsComponent.AnalyticsParams(
|
||||
blockchain = null,
|
||||
source = "Market",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onChildBack() {
|
||||
if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) {
|
||||
stackNavigation.popWhile { it != FeedEntryChildFactory.Child.Feed }
|
||||
|
|
@ -102,4 +118,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
|||
interface Factory : FeedEntryComponent.Factory {
|
||||
override fun create(context: AppComponentContext): DefaultFeedEntryComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface FeedEntryClickIntents : FeedModelClickIntents
|
||||
|
|
@ -4,8 +4,6 @@ import androidx.compose.runtime.Immutable
|
|||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.navigation.Route
|
||||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.features.feed.components.feed.DefaultFeedComponent
|
||||
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
|
||||
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
|
||||
|
|
@ -44,7 +42,7 @@ internal class FeedEntryChildFactory @Inject constructor() {
|
|||
fun createChild(
|
||||
child: Child,
|
||||
appComponentContext: AppComponentContext,
|
||||
onTokenClick: (TokenMarketParams, AppCurrency) -> Unit,
|
||||
feedEntryClickIntents: FeedEntryClickIntents,
|
||||
): ComposableModularContentComponent {
|
||||
return when (child) {
|
||||
is Child.TokenDetails -> {
|
||||
|
|
@ -56,7 +54,12 @@ internal class FeedEntryChildFactory @Inject constructor() {
|
|||
is Child.TokenList -> {
|
||||
DefaultMarketsTokenListComponent(
|
||||
appComponentContext = appComponentContext,
|
||||
onTokenClick = onTokenClick,
|
||||
onTokenClick = { token, appCurrency ->
|
||||
feedEntryClickIntents.onMarketItemClick(
|
||||
token,
|
||||
appCurrency,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Child.NewsDetails -> {
|
||||
|
|
@ -72,6 +75,7 @@ internal class FeedEntryChildFactory @Inject constructor() {
|
|||
Child.Feed -> {
|
||||
DefaultFeedComponent(
|
||||
appComponentContext = appComponentContext,
|
||||
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,22 @@ package com.tangem.features.feed.components.feed
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableModularContentComponent
|
||||
import com.tangem.features.feed.model.feed.FeedComponentModel
|
||||
import com.tangem.features.feed.ui.feed.FeedListContent
|
||||
import com.tangem.features.feed.model.feed.FeedModelClickIntents
|
||||
import com.tangem.features.feed.ui.feed.FeedList
|
||||
import com.tangem.features.feed.ui.feed.FeedListHeader
|
||||
|
||||
internal class DefaultFeedComponent(
|
||||
appComponentContext: AppComponentContext,
|
||||
private val params: FeedParams,
|
||||
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val feedComponentModel = getOrCreateModel<FeedComponentModel>()
|
||||
private val feedComponentModel = getOrCreateModel<FeedComponentModel, FeedParams>(params = params)
|
||||
|
||||
@Composable
|
||||
override fun Title() {
|
||||
|
|
@ -25,8 +28,15 @@ internal class DefaultFeedComponent(
|
|||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
LifecycleStartEffect(Unit) {
|
||||
feedComponentModel.isVisibleOnScreen.value = true
|
||||
onStopOrDispose {
|
||||
feedComponentModel.isVisibleOnScreen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
val state by feedComponentModel.state.collectAsStateWithLifecycle()
|
||||
FeedListContent(
|
||||
FeedList(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
)
|
||||
|
|
@ -34,4 +44,6 @@ internal class DefaultFeedComponent(
|
|||
|
||||
@Composable
|
||||
override fun Footer() = Unit
|
||||
|
||||
data class FeedParams(val feedClickIntents: FeedModelClickIntents)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
|
|||
import arrow.core.getOrElse
|
||||
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.components.fields.entity.SearchBarUM
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.utils.DateTimeFormatters
|
||||
|
|
@ -11,8 +12,10 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
|||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase
|
||||
import com.tangem.domain.markets.TokenMarketListConfig
|
||||
import com.tangem.domain.markets.toSerializableParam
|
||||
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.impl.R
|
||||
import com.tangem.features.feed.ui.feed.state.*
|
||||
import com.tangem.features.feed.ui.market.state.SortByTypeUM
|
||||
|
|
@ -37,10 +40,10 @@ internal class FeedComponentModel @Inject constructor(
|
|||
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
|
||||
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
|
||||
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
private val _state = MutableStateFlow(initialState())
|
||||
val state = _state.asStateFlow()
|
||||
private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>()
|
||||
|
||||
private var quotesUpdateJob: Job? = null
|
||||
|
||||
|
|
@ -59,42 +62,50 @@ internal class FeedComponentModel @Inject constructor(
|
|||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
internal val state: StateFlow<FeedListUM>
|
||||
field = MutableStateFlow<FeedListUM>(initialState())
|
||||
|
||||
private val searchBarStateFactory by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SearchBarStateFactory(
|
||||
currentStateProvider = Provider { _state.value },
|
||||
onStateUpdate = { newState -> _state.update { newState } },
|
||||
currentStateProvider = Provider { state.value },
|
||||
onStateUpdate = { newState -> state.update { newState } },
|
||||
)
|
||||
}
|
||||
|
||||
private val trendingNewsStateFactory by lazy(LazyThreadSafetyMode.NONE) {
|
||||
TrendingNewsStateFactory(
|
||||
currentStateProvider = Provider { _state.value },
|
||||
onStateUpdate = { newState -> _state.update { newState } },
|
||||
currentStateProvider = Provider { state.value },
|
||||
onStateUpdate = { newState -> state.update { newState } },
|
||||
)
|
||||
}
|
||||
|
||||
val isVisibleOnScreen = MutableStateFlow(false)
|
||||
|
||||
init {
|
||||
updateCallbacks()
|
||||
|
||||
modelScope.launch(dispatchers.default) {
|
||||
fetchTrendingNewsUseCase()
|
||||
subscribeOnTrendingNews()
|
||||
}
|
||||
_state.update { feedListUM ->
|
||||
feedListUM.copy(
|
||||
searchBar = _state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange),
|
||||
feedListCallbacks = feedListUM.feedListCallbacks.copy(
|
||||
onSortTypeClick = ::onSortTypeClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
modelScope.launch(dispatchers.default) {
|
||||
combine(
|
||||
marketsBatchFlowManager.itemsByOrder,
|
||||
marketsBatchFlowManager.loadingStatesByOrder,
|
||||
marketsBatchFlowManager.errorStatesByOrder,
|
||||
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder ->
|
||||
flow = marketsBatchFlowManager.itemsByOrder,
|
||||
flow2 = marketsBatchFlowManager.loadingStatesByOrder,
|
||||
flow3 = marketsBatchFlowManager.errorStatesByOrder,
|
||||
flow4 = manageTrendingNewsUseCase.observeTrendingNews(),
|
||||
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult ->
|
||||
updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder)
|
||||
val currentSortType = _state.value.marketChartConfig.currentSortByType
|
||||
trendingNewsStateFactory.updateTrendingNewsState(
|
||||
result = trendingNewsResult,
|
||||
onRetryClicked = {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
fetchTrendingNewsUseCase.invoke()
|
||||
}
|
||||
},
|
||||
)
|
||||
updateGlobalState()
|
||||
val currentSortType = state.value.marketChartConfig.currentSortByType
|
||||
val items = itemsByOrder[currentSortType]
|
||||
val isLoading = loadingStatesByOrder[currentSortType] == true
|
||||
if (items != null && items.isNotEmpty() && !isLoading) {
|
||||
|
|
@ -122,12 +133,6 @@ internal class FeedComponentModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun subscribeOnTrendingNews() {
|
||||
manageTrendingNewsUseCase().collect { articles ->
|
||||
trendingNewsStateFactory.updateTrendingNewsState(articles)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialState(): FeedListUM {
|
||||
return FeedListUM(
|
||||
currentDate = getCurrentDate(),
|
||||
|
|
@ -151,11 +156,12 @@ internal class FeedComponentModel @Inject constructor(
|
|||
marketChartConfig = MarketChartConfig(
|
||||
marketCharts = buildMap {
|
||||
SortByTypeUM.entries.forEach {
|
||||
put(it, MarketChartUM.LoadingError(onRetryClicked = marketsBatchFlowManager::reloadAll))
|
||||
put(it, MarketChartUM.Loading)
|
||||
}
|
||||
}.toPersistentHashMap(),
|
||||
currentSortByType = SortByTypeUM.Trending,
|
||||
),
|
||||
globalState = GlobalFeedState.Loading,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +175,7 @@ internal class FeedComponentModel @Inject constructor(
|
|||
loadingStatesByOrder: Map<SortByTypeUM, Boolean>,
|
||||
errorStatesByOrder: Map<SortByTypeUM, Boolean>,
|
||||
) {
|
||||
_state.update { currentState ->
|
||||
state.update { currentState ->
|
||||
val newMarketCharts = buildMap {
|
||||
SortByTypeUM.entries.forEach { sortByType ->
|
||||
val items = itemsByOrder[sortByType] ?: persistentListOf()
|
||||
|
|
@ -224,8 +230,41 @@ internal class FeedComponentModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateGlobalState() {
|
||||
state.update { currentState ->
|
||||
val newsState = currentState.news
|
||||
val marketCharts = currentState.marketChartConfig.marketCharts
|
||||
|
||||
val isNewsLoading = newsState is NewsUM.Loading
|
||||
val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading }
|
||||
|
||||
val isNewsError = newsState is NewsUM.Error
|
||||
val areAllChartsError = marketCharts.values.all { it is MarketChartUM.LoadingError }
|
||||
|
||||
val newGlobalState = when {
|
||||
isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading
|
||||
isNewsError && areAllChartsError -> GlobalFeedState.Error(
|
||||
onRetryClicked = {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
fetchTrendingNewsUseCase.invoke()
|
||||
marketsBatchFlowManager.reloadAll()
|
||||
}
|
||||
},
|
||||
)
|
||||
else -> GlobalFeedState.Content
|
||||
}
|
||||
|
||||
val currentGlobalState = currentState.globalState
|
||||
if (currentGlobalState::class != newGlobalState::class) {
|
||||
currentState.copy(globalState = newGlobalState)
|
||||
} else {
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSortTypeClick(sortByType: SortByTypeUM) {
|
||||
_state.update { currentState ->
|
||||
state.update { currentState ->
|
||||
val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) ->
|
||||
when (chart) {
|
||||
is MarketChartUM.Content -> {
|
||||
|
|
@ -256,11 +295,41 @@ internal class FeedComponentModel @Inject constructor(
|
|||
quotesUpdateJob = modelScope.launch {
|
||||
while (true) {
|
||||
delay(DELAY_TO_FETCH_QUOTES)
|
||||
isVisibleOnScreen.first { it }
|
||||
marketsBatchFlowManager.updateQuotes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCallbacks() {
|
||||
state.update { feedListUM ->
|
||||
feedListUM.copy(
|
||||
searchBar = state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange),
|
||||
feedListCallbacks = feedListUM.feedListCallbacks.copy(
|
||||
onSortTypeClick = ::onSortTypeClick,
|
||||
onMarketItemClick = { item ->
|
||||
val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id)
|
||||
if (tokenMarket != null) {
|
||||
params.feedClickIntents.onMarketItemClick(
|
||||
token = tokenMarket.toSerializableParam(),
|
||||
appCurrency = currentAppCurrency.value,
|
||||
)
|
||||
}
|
||||
},
|
||||
onMarketOpenClick = { sortBy ->
|
||||
params.feedClickIntents.onMarketOpenClick(sortBy)
|
||||
},
|
||||
onArticleClick = { articleId ->
|
||||
params.feedClickIntents.onArticleClick(articleId)
|
||||
},
|
||||
onOpenAllNews = {
|
||||
params.feedClickIntents.onOpenAllNews()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order {
|
||||
return when (this) {
|
||||
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.features.feed.model.feed
|
||||
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.features.feed.ui.market.state.SortByTypeUM
|
||||
|
||||
/**
|
||||
* Callback interface for feed model navigation actions.
|
||||
*/
|
||||
internal interface FeedModelClickIntents {
|
||||
fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency)
|
||||
fun onMarketOpenClick(sortBy: SortByTypeUM)
|
||||
fun onArticleClick(articleId: Int)
|
||||
fun onOpenAllNews()
|
||||
}
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
package com.tangem.features.feed.ui.feed
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
|
|
@ -31,7 +28,6 @@ import androidx.compose.ui.text.withStyle
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import com.tangem.common.ui.markets.MarketChartLoadingError
|
||||
import com.tangem.common.ui.markets.MarketsListItem
|
||||
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
|
|
@ -40,6 +36,7 @@ import com.tangem.common.ui.news.ArticleConfigUM
|
|||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.SpacerW
|
||||
import com.tangem.core.ui.components.UnableToLoadData
|
||||
import com.tangem.core.ui.components.block.BlockCard
|
||||
import com.tangem.core.ui.components.block.TangemBlockCardColors
|
||||
import com.tangem.core.ui.components.buttons.SecondarySmallButton
|
||||
|
|
@ -74,77 +71,123 @@ internal fun FeedListHeader(searchBarUM: SearchBarUM, modifier: Modifier = Modif
|
|||
}
|
||||
|
||||
@Composable
|
||||
internal fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
|
||||
internal fun FeedList(state: FeedListUM, modifier: Modifier = Modifier) {
|
||||
val background = LocalMainBottomSheetColor.current.value
|
||||
|
||||
AnimatedContent(
|
||||
modifier = modifier,
|
||||
targetState = state.globalState,
|
||||
) { animatedState ->
|
||||
when (animatedState) {
|
||||
is GlobalFeedState.Loading -> {
|
||||
FeeListLoading(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.drawBehind { drawRect(background) },
|
||||
)
|
||||
}
|
||||
is GlobalFeedState.Error -> {
|
||||
FeedListGlobalError(
|
||||
onRetryClick = animatedState.onRetryClicked,
|
||||
modifier = Modifier.drawBehind { drawRect(background) },
|
||||
)
|
||||
}
|
||||
is GlobalFeedState.Content -> {
|
||||
FeeListContent(
|
||||
modifier = Modifier,
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeeListContent(state: FeedListUM, modifier: Modifier = Modifier) {
|
||||
val background = LocalMainBottomSheetColor.current.value
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.drawBehind { drawRect(background) },
|
||||
) {
|
||||
SpacerH(20.dp)
|
||||
|
||||
Text(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
text = stringResourceSafe(R.string.feed_market_and_news),
|
||||
style = TangemTheme.typography.h2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
text = state.currentDate,
|
||||
style = TangemTheme.typography.h2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
.fillMaxSize()
|
||||
.padding(WindowInsets.navigationBars.asPaddingValues()),
|
||||
) {
|
||||
SpacerH(20.dp)
|
||||
|
||||
SpacerH(32.dp)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
text = stringResourceSafe(R.string.feed_market_and_news),
|
||||
style = TangemTheme.typography.h2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
text = state.currentDate,
|
||||
style = TangemTheme.typography.h2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
|
||||
state.marketChartConfig.marketCharts[SortByTypeUM.Rating]?.let { marketChartUM ->
|
||||
MarketBlock(
|
||||
marketChart = marketChartUM,
|
||||
SpacerH(32.dp)
|
||||
|
||||
AnimatedVisibility(state.marketChartConfig.marketCharts[SortByTypeUM.Rating] != null) {
|
||||
val marketChart = remember(state.marketChartConfig.marketCharts[SortByTypeUM.Rating]) {
|
||||
state.marketChartConfig.marketCharts[SortByTypeUM.Rating]
|
||||
}
|
||||
if (marketChart != null) {
|
||||
MarketBlock(
|
||||
marketChart = marketChart,
|
||||
feedListCallbacks = state.feedListCallbacks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NewsBlock(
|
||||
news = state.news,
|
||||
feedListCallbacks = state.feedListCallbacks,
|
||||
trendingArticle = state.trendingArticle,
|
||||
)
|
||||
|
||||
MarketPulseBlock(
|
||||
marketChartConfig = state.marketChartConfig,
|
||||
feedListCallbacks = state.feedListCallbacks,
|
||||
)
|
||||
}
|
||||
|
||||
NewsBlock(
|
||||
news = state.news,
|
||||
feedListCallbacks = state.feedListCallbacks,
|
||||
trendingArticle = state.trendingArticle,
|
||||
)
|
||||
|
||||
MarketPulseBlock(
|
||||
marketChartConfig = state.marketChartConfig,
|
||||
feedListCallbacks = state.feedListCallbacks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarketBlock(marketChart: MarketChartUM, feedListCallbacks: FeedListCallbacks) {
|
||||
Header(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.markets_common_title),
|
||||
style = TangemTheme.typography.h3,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
},
|
||||
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Header(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.markets_common_title),
|
||||
style = TangemTheme.typography.h3,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
},
|
||||
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
|
||||
)
|
||||
|
||||
SpacerH(12.dp)
|
||||
SpacerH(12.dp)
|
||||
|
||||
Charts(
|
||||
onItemClick = feedListCallbacks.onMarketItemClick,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
marketChart = marketChart,
|
||||
)
|
||||
SpacerH(32.dp)
|
||||
Charts(
|
||||
onItemClick = feedListCallbacks.onMarketItemClick,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
marketChart = marketChart,
|
||||
)
|
||||
|
||||
SpacerH(32.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -219,6 +262,9 @@ private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendi
|
|||
NewsUM.Loading -> {
|
||||
NewsLoadingBlock()
|
||||
}
|
||||
is NewsUM.Error -> {
|
||||
NewsErrorBlock(onRetryClick = newsUM.onRetryClicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -342,9 +388,9 @@ private fun Charts(
|
|||
}
|
||||
}
|
||||
is MarketChartUM.LoadingError -> {
|
||||
MarketChartLoadingError(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
UnableToLoadData(
|
||||
onRetryClick = marketChart.onRetryClicked,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
is MarketChartUM.Content -> {
|
||||
|
|
@ -392,6 +438,43 @@ private fun FilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick:
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedListGlobalError(onRetryClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val background = LocalMainBottomSheetColor.current.value
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.drawBehind { drawRect(background) }
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
UnableToLoadData(onRetryClick = onRetryClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsErrorBlock(onRetryClick: () -> Unit) {
|
||||
Column {
|
||||
Header(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.common_news),
|
||||
style = TangemTheme.typography.h3,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
}
|
||||
},
|
||||
onSeeAllClick = {},
|
||||
)
|
||||
SpacerH(12.dp)
|
||||
UnableToLoadData(
|
||||
onRetryClick = onRetryClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_CHART_SIZE_IN_MARKET = 5
|
||||
private const val GRADIENT_START = 0f
|
||||
private const val GRADIENT_END = 0.5f
|
||||
|
|
@ -402,6 +485,6 @@ private val LinearGradientSecondPart = Color(0xFFE05AED)
|
|||
@Composable
|
||||
private fun FeedListPreview() {
|
||||
TangemThemePreview {
|
||||
FeedListContent(state = createFeedPreviewState())
|
||||
FeedList(state = createFeedPreviewState())
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,21 @@ import com.tangem.core.ui.components.SpacerH
|
|||
import com.tangem.core.ui.components.block.BlockCard
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
|
||||
@Composable
|
||||
internal fun FeeListLoading(modifier: Modifier = Modifier) {
|
||||
Column(modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(WindowInsets.navigationBars.asPaddingValues()),
|
||||
) {
|
||||
MarketLoadingBlock()
|
||||
NewsLoadingBlock()
|
||||
MarketPulseLoadingBlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MarketLoadingBlock() {
|
||||
RectangleShimmer(
|
||||
|
|
@ -23,15 +38,19 @@ internal fun MarketLoadingBlock() {
|
|||
.padding(start = 16.dp)
|
||||
.size(width = 104.dp, height = 18.dp),
|
||||
)
|
||||
SpacerH(12.dp)
|
||||
SpacerH(15.dp)
|
||||
ChartsLoading(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
SpacerH(32.dp)
|
||||
SpacerH(35.dp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MarketPulseLoadingBlock() {
|
||||
RectangleShimmer()
|
||||
SpacerH(8.dp)
|
||||
RectangleShimmer(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp)
|
||||
.size(width = 104.dp, height = 18.dp),
|
||||
)
|
||||
SpacerH(15.dp)
|
||||
LazyRow(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -43,7 +62,7 @@ internal fun MarketPulseLoadingBlock() {
|
|||
RectangleShimmer(modifier = Modifier.size(width = 124.dp, height = 36.dp))
|
||||
}
|
||||
}
|
||||
SpacerH(12.dp)
|
||||
SpacerH(16.dp)
|
||||
ChartsLoading(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
SpacerH(32.dp)
|
||||
}
|
||||
|
|
@ -51,8 +70,12 @@ internal fun MarketPulseLoadingBlock() {
|
|||
@Composable
|
||||
internal fun NewsLoadingBlock() {
|
||||
Column {
|
||||
RectangleShimmer()
|
||||
SpacerH(12.dp)
|
||||
RectangleShimmer(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp)
|
||||
.size(width = 104.dp, height = 18.dp),
|
||||
)
|
||||
SpacerH(15.dp)
|
||||
TrendingLoadingArticle(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
SpacerH(12.dp)
|
||||
LazyRow(
|
||||
|
|
@ -65,6 +88,7 @@ internal fun NewsLoadingBlock() {
|
|||
DefaultLoadingArticle()
|
||||
}
|
||||
}
|
||||
SpacerH(35.dp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,10 +114,8 @@ private const val DEFAULT_CHART_SIZE_IN_MARKET = 5
|
|||
private fun FeedListLoadingPreview() {
|
||||
TangemThemePreview {
|
||||
Column {
|
||||
NewsLoadingBlock()
|
||||
SpacerH(10.dp)
|
||||
MarketLoadingBlock()
|
||||
SpacerH(10.dp)
|
||||
NewsLoadingBlock()
|
||||
MarketPulseLoadingBlock()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ internal data class FeedListUM(
|
|||
val news: NewsUM,
|
||||
val trendingArticle: ArticleConfigUM?,
|
||||
val marketChartConfig: MarketChartConfig,
|
||||
val globalState: GlobalFeedState = GlobalFeedState.Content,
|
||||
)
|
||||
|
||||
internal data class FeedListCallbacks(
|
||||
|
|
@ -31,6 +32,7 @@ internal data class FeedListCallbacks(
|
|||
internal sealed interface NewsUM {
|
||||
data object Loading : NewsUM
|
||||
data class Content(val content: ImmutableList<ArticleConfigUM>) : NewsUM
|
||||
data class Error(val onRetryClicked: () -> Unit) : NewsUM
|
||||
}
|
||||
|
||||
internal data class MarketChartConfig(
|
||||
|
|
@ -56,4 +58,11 @@ internal sealed interface MarketChartUM {
|
|||
internal data class SortChartConfigUM(
|
||||
val sortByType: SortByTypeUM,
|
||||
val isSelected: Boolean,
|
||||
)
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal sealed interface GlobalFeedState {
|
||||
data object Loading : GlobalFeedState
|
||||
data object Content : GlobalFeedState
|
||||
data class Error(val onRetryClicked: () -> Unit) : GlobalFeedState
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.tangem.features.feed.ui.feed.state
|
|||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.*
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.features.feed.model.converter.MarketsTokenItemConverter
|
||||
import com.tangem.features.feed.ui.market.state.MarketsListUM
|
||||
import com.tangem.features.feed.ui.market.state.SortByTypeUM
|
||||
|
|
@ -126,6 +127,11 @@ internal class FeedMarketsBatchFlowManager(
|
|||
return managersByOrder[order]?.onLastBatchLoadedSuccess
|
||||
}
|
||||
|
||||
fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? {
|
||||
return managersByOrder.values
|
||||
.firstNotNullOfOrNull { manager -> manager.getTokenMarketById(tokenId) }
|
||||
}
|
||||
|
||||
private class SingleOrderManager(
|
||||
val order: TokenMarketListConfig.Order,
|
||||
private val actionsFlow: MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>,
|
||||
|
|
@ -359,6 +365,13 @@ internal class FeedMarketsBatchFlowManager(
|
|||
}
|
||||
}
|
||||
|
||||
fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? {
|
||||
return resultBatches.value.processedItems
|
||||
?.asSequence()
|
||||
?.flatMap { it.data }
|
||||
?.firstOrNull { it.id == tokenId }
|
||||
}
|
||||
|
||||
private data class ResultBatches(
|
||||
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
|
||||
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.tangem.core.ui.utils.getFormattedDate
|
|||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.news.ShortArticle
|
||||
import com.tangem.domain.models.news.TrendingNews
|
||||
import com.tangem.features.feed.impl.R
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.StringsSigns
|
||||
|
|
@ -23,71 +24,78 @@ internal class TrendingNewsStateFactory(
|
|||
private val onStateUpdate: (FeedListUM) -> Unit,
|
||||
) {
|
||||
|
||||
fun updateTrendingNewsState(news: List<ShortArticle>) {
|
||||
val trendingArticleIndex = news.indexOfFirst { it.isTrending }
|
||||
val trendingArticle = if (trendingArticleIndex != -1) news[trendingArticleIndex] else null
|
||||
val commonArticles = if (trendingArticleIndex != -1) {
|
||||
news.toMutableList().apply { removeAt(trendingArticleIndex) }
|
||||
} else {
|
||||
news
|
||||
}
|
||||
fun updateTrendingNewsState(result: TrendingNews, onRetryClicked: () -> Unit) {
|
||||
val currentState = currentStateProvider()
|
||||
when (result) {
|
||||
is TrendingNews.Data -> handleDataState(currentState, result.articles)
|
||||
is TrendingNews.Error -> handleErrorState(currentState, onRetryClicked)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDataState(currentState: FeedListUM, articles: List<ShortArticle>) {
|
||||
val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles)
|
||||
|
||||
onStateUpdate(
|
||||
currentState.copy(
|
||||
trendingArticle = trendingArticle?.let { article ->
|
||||
ArticleConfigUM(
|
||||
id = article.id,
|
||||
title = article.title,
|
||||
score = article.score,
|
||||
isTrending = true,
|
||||
tags = article.categories.map { category ->
|
||||
LabelUM(text = TextReference.Str(category.name))
|
||||
}.plus(
|
||||
article.relatedTokens.map { token ->
|
||||
LabelUM(
|
||||
text = TextReference.Str(token.symbol),
|
||||
leadingContent = LabelLeadingContentUM.Token(
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(
|
||||
tokenId = CryptoCurrency.RawID(token.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
).toPersistentSet(),
|
||||
createdAt = mapFormattedDate(article.createdAt),
|
||||
isViewed = article.viewed,
|
||||
)
|
||||
},
|
||||
trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) },
|
||||
news = NewsUM.Content(
|
||||
commonArticles.map { article ->
|
||||
ArticleConfigUM(
|
||||
id = article.id,
|
||||
title = article.title,
|
||||
score = article.score,
|
||||
isTrending = false,
|
||||
tags = article.categories.map { category ->
|
||||
LabelUM(text = TextReference.Str(category.name))
|
||||
}.plus(
|
||||
article.relatedTokens.map { token ->
|
||||
LabelUM(
|
||||
text = TextReference.Str(token.symbol),
|
||||
leadingContent = LabelLeadingContentUM.Token(
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(
|
||||
tokenId = CryptoCurrency.RawID(token.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
).toPersistentSet(),
|
||||
createdAt = mapFormattedDate(article.createdAt),
|
||||
isViewed = article.viewed,
|
||||
)
|
||||
}.toPersistentList(),
|
||||
commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleErrorState(currentState: FeedListUM, onRetryClicked: () -> Unit) {
|
||||
onStateUpdate(
|
||||
currentState.copy(
|
||||
trendingArticle = null,
|
||||
news = NewsUM.Error(onRetryClicked = onRetryClicked),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun separateTrendingAndCommonArticles(
|
||||
articles: List<ShortArticle>,
|
||||
): Pair<ShortArticle?, List<ShortArticle>> {
|
||||
val trendingArticleIndex = articles.indexOfFirst { it.isTrending }
|
||||
return if (trendingArticleIndex != -1) {
|
||||
val trendingArticle = articles[trendingArticleIndex]
|
||||
val commonArticles = articles.toMutableList().apply { removeAt(trendingArticleIndex) }
|
||||
trendingArticle to commonArticles
|
||||
} else {
|
||||
null to articles
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapToArticleConfigUM(article: ShortArticle, isTrending: Boolean): ArticleConfigUM {
|
||||
return ArticleConfigUM(
|
||||
id = article.id,
|
||||
title = article.title,
|
||||
score = article.score,
|
||||
isTrending = isTrending,
|
||||
tags = buildArticleTags(article),
|
||||
createdAt = mapFormattedDate(article.createdAt),
|
||||
isViewed = article.viewed,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet<LabelUM> {
|
||||
val categoryLabels = article.categories.map { category ->
|
||||
LabelUM(text = TextReference.Str(category.name))
|
||||
}
|
||||
val tokenLabels = article.relatedTokens.map { token ->
|
||||
LabelUM(
|
||||
text = TextReference.Str(token.symbol),
|
||||
leadingContent = LabelLeadingContentUM.Token(
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(
|
||||
tokenId = CryptoCurrency.RawID(token.id),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return (categoryLabels + tokenLabels).toPersistentSet()
|
||||
}
|
||||
|
||||
private fun mapFormattedDate(createdAt: String): TextReference {
|
||||
val formattedDate = getFormattedDate(
|
||||
createdAt = createdAt,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue