Updated on 2026-08-14
This commit is contained in:
parent
7d599256d1
commit
028b21b112
13 changed files with 574 additions and 86 deletions
|
|
@ -41,8 +41,9 @@ fun TrendingLoadingArticle(modifier: Modifier = Modifier) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun DefaultLoadingArticle() {
|
||||
fun DefaultLoadingArticle(modifier: Modifier = Modifier) {
|
||||
BlockCard(
|
||||
modifier = modifier,
|
||||
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
|
|
|
|||
|
|
@ -817,6 +817,7 @@
|
|||
</plurals>
|
||||
<string name="news_quick_recap">Quick recap</string>
|
||||
<string name="news_related_news">Related News</string>
|
||||
<string name="news_related_tokens">Related tokens</string>
|
||||
<string name="news_sources">Sources</string>
|
||||
<string name="news_stay_in_the_loop">Stay in the loop</string>
|
||||
<string name="nfc_error_unavailable">NFC is not available on your device</string>
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
|||
articleId = articleId,
|
||||
onBackClicked = { onChildBack() },
|
||||
preselectedArticlesId = preselectedArticlesId,
|
||||
onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -195,6 +196,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
|||
articleId = entryRoute.articleId,
|
||||
onBackClicked = { router.pop() },
|
||||
preselectedArticlesId = entryRoute.preselectedArticlesId,
|
||||
onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) },
|
||||
),
|
||||
)
|
||||
null -> FeedEntryChildFactory.Child.Feed
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
|
|||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.features.feed.model.news.details.NewsDetailsModel
|
||||
import com.tangem.features.feed.ui.news.details.NewsDetailsContent
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -57,8 +59,7 @@ internal class DefaultNewsDetailsComponent(
|
|||
data class Params(
|
||||
val articleId: Int,
|
||||
val onBackClicked: () -> Unit,
|
||||
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
|
||||
val preselectedArticlesId: List<Int> = emptyList(),
|
||||
val tokenIds: List<String> = emptyList(),
|
||||
val categoryIds: List<Int> = emptyList(),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +1,38 @@
|
|||
package com.tangem.features.feed.model.news.details
|
||||
|
||||
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.navigation.share.ShareManager
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
|
||||
import com.tangem.domain.markets.GetTokenPriceChartUseCase
|
||||
import com.tangem.domain.markets.PriceChangeInterval
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.news.usecase.ObserveNewsDetailsUseCase
|
||||
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
|
||||
import com.tangem.features.feed.model.news.details.converter.NewsDetailsConverter
|
||||
import com.tangem.features.feed.model.news.details.converter.RelatedTokenConverter
|
||||
import com.tangem.features.feed.model.news.details.converter.TokenMarketInfoToParamsConverter
|
||||
import com.tangem.features.feed.model.news.details.factory.NewsDetailsStateFactory
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.domain.models.news.RelatedToken
|
||||
import com.tangem.features.feed.ui.news.details.state.ArticleUM
|
||||
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
|
||||
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.indexOfFirstOrNull
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
|
@ -24,21 +43,31 @@ import javax.inject.Inject
|
|||
@Suppress("LongParameterList")
|
||||
internal class NewsDetailsModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
||||
private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase,
|
||||
private val urlOpener: UrlOpener,
|
||||
private val shareManager: ShareManager,
|
||||
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
|
||||
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<DefaultNewsDetailsComponent.Params>()
|
||||
|
||||
private val currentLanguage = Locale.getDefault().language
|
||||
private val relatedTokensCache = mutableMapOf<Int, RelatedTokensUM>()
|
||||
|
||||
private val converter = NewsDetailsConverter(
|
||||
onSourceClick = { url ->
|
||||
urlOpener.openUrl(url)
|
||||
},
|
||||
)
|
||||
private val newsDetailsConverter = NewsDetailsConverter(onSourceClick = urlOpener::openUrl)
|
||||
private val tokenMarketInfoToParamsConverter = TokenMarketInfoToParamsConverter()
|
||||
|
||||
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
|
||||
maybeAppCurrency.getOrElse { AppCurrency.Default }
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = AppCurrency.Default,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
NewsDetailsUM(
|
||||
|
|
@ -51,6 +80,14 @@ internal class NewsDetailsModel @Inject constructor(
|
|||
),
|
||||
)
|
||||
|
||||
private val stateFactory by lazy(LazyThreadSafetyMode.NONE) {
|
||||
NewsDetailsStateFactory(
|
||||
currentStateProvider = Provider { _state.value },
|
||||
shareManager = shareManager,
|
||||
onStateUpdate = { newState -> _state.update { newState } },
|
||||
)
|
||||
}
|
||||
|
||||
val state: StateFlow<NewsDetailsUM> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
|
|
@ -62,17 +99,9 @@ internal class NewsDetailsModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onArticleIndexChanged(newIndex: Int) {
|
||||
stateFactory.updateSelectedArticleIndex(newIndex)
|
||||
val currentArticle = state.value.articles.getOrNull(newIndex)
|
||||
_state.update { currentState ->
|
||||
currentState.copy(
|
||||
selectedArticleIndex = newIndex,
|
||||
onShareClick = {
|
||||
currentArticle?.let {
|
||||
shareManager.shareText(it.newsUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
currentArticle?.let(::loadRelatedTokens)
|
||||
}
|
||||
|
||||
private fun handlePreselectedArticles() {
|
||||
|
|
@ -87,7 +116,7 @@ internal class NewsDetailsModel @Inject constructor(
|
|||
.map { articlesMap ->
|
||||
params.preselectedArticlesId.mapNotNull { articleId ->
|
||||
articlesMap[articleId]?.let { detailedArticle ->
|
||||
converter.convert(detailedArticle)
|
||||
newsDetailsConverter.convert(detailedArticle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,20 +125,137 @@ internal class NewsDetailsModel @Inject constructor(
|
|||
}
|
||||
.onEach { articles ->
|
||||
val selectedIndex = articles.indexOfFirstOrNull { it.id == params.articleId } ?: 0
|
||||
stateFactory.updateArticles(articles, selectedIndex)
|
||||
val currentArticle = articles.getOrNull(selectedIndex)
|
||||
_state.update { newsDetailsUM ->
|
||||
newsDetailsUM.copy(
|
||||
articles = articles,
|
||||
selectedArticleIndex = selectedIndex,
|
||||
onShareClick = {
|
||||
currentArticle?.let {
|
||||
shareManager.shareText(it.newsUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
currentArticle?.let(::loadRelatedTokens)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRelatedTokens(article: ArticleUM) {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
val cachedTokens = getCachedRelatedTokens(article.id)
|
||||
if (cachedTokens != null) {
|
||||
stateFactory.updateRelatedTokens(cachedTokens)
|
||||
return@launch
|
||||
}
|
||||
|
||||
stateFactory.updateRelatedTokens(RelatedTokensUM.Loading)
|
||||
|
||||
val relatedTokens = article.relatedTokens.take(RELATED_TOKEN_MAX_COUNT)
|
||||
if (relatedTokens.isEmpty()) {
|
||||
handleEmptyRelatedTokens(article.id)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val appCurrency = currentAppCurrency.value
|
||||
val tokenDataList = loadTokensData(relatedTokens, appCurrency)
|
||||
|
||||
if (tokenDataList.isEmpty()) {
|
||||
handleEmptyTokenData(article.id)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val resultState = createRelatedTokensState(tokenDataList, appCurrency)
|
||||
relatedTokensCache[article.id] = resultState
|
||||
stateFactory.updateRelatedTokens(resultState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCachedRelatedTokens(articleId: Int): RelatedTokensUM? {
|
||||
return relatedTokensCache[articleId]?.takeIf { it !is RelatedTokensUM.Loading }
|
||||
}
|
||||
|
||||
private fun handleEmptyRelatedTokens(articleId: Int) {
|
||||
val errorState = RelatedTokensUM.LoadingError
|
||||
relatedTokensCache[articleId] = errorState
|
||||
stateFactory.updateRelatedTokens(errorState)
|
||||
}
|
||||
|
||||
private suspend fun CoroutineScope.loadTokensData(
|
||||
relatedTokens: List<RelatedToken>,
|
||||
appCurrency: AppCurrency,
|
||||
): List<Pair<MarketsListItemUM, TokenMarketParams>> {
|
||||
val relatedTokenConverter = RelatedTokenConverter(appCurrency = appCurrency)
|
||||
|
||||
return relatedTokens.map { token ->
|
||||
async(dispatchers.default) {
|
||||
loadSingleTokenData(token, appCurrency, relatedTokenConverter)
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
private suspend fun loadSingleTokenData(
|
||||
token: RelatedToken,
|
||||
appCurrency: AppCurrency,
|
||||
relatedTokenConverter: RelatedTokenConverter,
|
||||
): Pair<MarketsListItemUM, TokenMarketParams>? {
|
||||
val tokenId = CryptoCurrency.RawID(token.id)
|
||||
val tokenInfoResult = getTokenMarketInfoUseCase(
|
||||
appCurrency = appCurrency,
|
||||
tokenId = tokenId,
|
||||
tokenSymbol = token.symbol,
|
||||
)
|
||||
|
||||
return tokenInfoResult.fold(
|
||||
ifLeft = { null },
|
||||
ifRight = { tokenInfo ->
|
||||
val chart = loadTokenChart(tokenId, token.symbol, appCurrency)
|
||||
val tokenItem = relatedTokenConverter.convert(tokenInfo to chart)
|
||||
val tokenParams = tokenMarketInfoToParamsConverter.convert(tokenInfo)
|
||||
tokenItem to tokenParams
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadTokenChart(
|
||||
tokenId: CryptoCurrency.RawID,
|
||||
tokenSymbol: String,
|
||||
appCurrency: AppCurrency,
|
||||
): com.tangem.domain.markets.TokenChart? {
|
||||
val chartResult = getTokenPriceChartUseCase(
|
||||
appCurrency = appCurrency,
|
||||
interval = PriceChangeInterval.H24,
|
||||
tokenId = tokenId,
|
||||
tokenSymbol = tokenSymbol,
|
||||
preview = true,
|
||||
)
|
||||
return chartResult.getOrElse { null }
|
||||
}
|
||||
|
||||
private fun handleEmptyTokenData(articleId: Int) {
|
||||
val errorState = RelatedTokensUM.LoadingError
|
||||
relatedTokensCache[articleId] = errorState
|
||||
stateFactory.updateRelatedTokens(errorState)
|
||||
}
|
||||
|
||||
private fun createRelatedTokensState(
|
||||
tokenDataList: List<Pair<MarketsListItemUM, TokenMarketParams>>,
|
||||
appCurrency: AppCurrency,
|
||||
): RelatedTokensUM.Content {
|
||||
val tokenItems = tokenDataList.map { it.first }
|
||||
val onTokenClick = createTokenClickHandler(tokenDataList, appCurrency)
|
||||
|
||||
return stateFactory.createRelatedTokensContent(
|
||||
items = tokenItems.toImmutableList(),
|
||||
onTokenClick = onTokenClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTokenClickHandler(
|
||||
tokenDataList: List<Pair<MarketsListItemUM, TokenMarketParams>>,
|
||||
appCurrency: AppCurrency,
|
||||
): (MarketsListItemUM) -> Unit {
|
||||
return { item ->
|
||||
val tokenData = tokenDataList.find { it.first.id == item.id }
|
||||
tokenData?.second?.let { tokenParams ->
|
||||
params.onTokenClick(tokenParams, appCurrency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal const val RELATED_TOKEN_MAX_COUNT = 5
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ internal class NewsDetailsConverter(
|
|||
content = value.content,
|
||||
sources = buildSources(value),
|
||||
newsUrl = value.newsUrl,
|
||||
relatedTokens = value.relatedTokens.toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ internal class NewsDetailsConverter(
|
|||
publishedAt = mapFormattedDate(originalArticle.publishedAt),
|
||||
url = originalArticle.url,
|
||||
onClick = { onSourceClick(originalArticle.url) },
|
||||
imageUrl = originalArticle.imageUrl,
|
||||
)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
package com.tangem.features.feed.model.news.details.converter
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.tangem.common.ui.charts.state.MarketChartData
|
||||
import com.tangem.common.ui.charts.state.MarketChartRawData
|
||||
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
|
||||
import com.tangem.common.ui.charts.state.sorted
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||
import com.tangem.core.ui.format.bigdecimal.*
|
||||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.markets.TokenChart
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.utils.converter.Converter
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
@Stable
|
||||
internal class RelatedTokenConverter(private val appCurrency: AppCurrency) :
|
||||
Converter<Pair<TokenMarketInfo, TokenChart?>, MarketsListItemUM> {
|
||||
|
||||
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
|
||||
|
||||
override fun convert(value: Pair<TokenMarketInfo, TokenChart?>): MarketsListItemUM {
|
||||
val (tokenInfo, chart) = value
|
||||
val tokenId = CryptoCurrency.RawID(tokenInfo.id)
|
||||
|
||||
return MarketsListItemUM(
|
||||
id = tokenId,
|
||||
name = tokenInfo.name,
|
||||
currencySymbol = tokenInfo.symbol,
|
||||
ratingPosition = tokenInfo.metrics?.marketRating?.toString(),
|
||||
marketCap = getMarketCap(tokenInfo),
|
||||
iconUrl = getTokenIconUrlFromDefaultHost(tokenId),
|
||||
price = getCurrentPrice(tokenInfo),
|
||||
trendPercentText = getTrendPercent(tokenInfo),
|
||||
trendType = getTrendType(tokenInfo),
|
||||
chartData = getChartData(chart),
|
||||
isUnder100kMarketCap = tokenInfo.metrics?.marketCap?.let { it < BigDecimal(MARKET_CAP_100K) } == true,
|
||||
stakingRate = null,
|
||||
updateTimestamp = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getMarketCap(tokenInfo: TokenMarketInfo): String? {
|
||||
val value = tokenInfo.metrics?.marketCap?.takeIf { it != BigDecimal.ZERO } ?: return null
|
||||
|
||||
return value.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
).compact(
|
||||
threeDigitsMethod = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentPrice(tokenInfo: TokenMarketInfo): MarketsListItemUM.Price {
|
||||
val priceText = tokenInfo.quotes.currentPrice.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
).price()
|
||||
}
|
||||
|
||||
return MarketsListItemUM.Price(
|
||||
text = priceText,
|
||||
changeType = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getChartData(chart: TokenChart?): MarketChartRawData? {
|
||||
return chart?.let { ct ->
|
||||
priceAndTimePointValuesConverter.convert(
|
||||
MarketChartData.Data(
|
||||
y = ct.priceY.toImmutableList(),
|
||||
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
|
||||
).sorted(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun getTrendType(tokenInfo: TokenMarketInfo): PriceChangeType {
|
||||
val scaled = tokenInfo.quotes.h24ChangePercent?.setScale(4, RoundingMode.HALF_UP)
|
||||
return when {
|
||||
scaled == null -> PriceChangeType.NEUTRAL
|
||||
scaled > BigDecimal.ZERO -> PriceChangeType.UP
|
||||
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
|
||||
else -> PriceChangeType.NEUTRAL
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTrendPercent(tokenInfo: TokenMarketInfo): String {
|
||||
return tokenInfo.quotes.h24ChangePercent?.format { percent() } ?: "0%"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MARKET_CAP_100K = "100000"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tangem.features.feed.model.news.details.converter
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
|
||||
import com.tangem.domain.markets.TokenMarketInfo
|
||||
import com.tangem.domain.markets.TokenMarketParams
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
@Stable
|
||||
internal class TokenMarketInfoToParamsConverter : Converter<TokenMarketInfo, TokenMarketParams> {
|
||||
|
||||
override fun convert(value: TokenMarketInfo): TokenMarketParams {
|
||||
val tokenId = CryptoCurrency.RawID(value.id)
|
||||
return TokenMarketParams(
|
||||
id = tokenId,
|
||||
name = value.name,
|
||||
symbol = value.symbol,
|
||||
tokenQuotes = TokenMarketParams.Quotes(
|
||||
currentPrice = value.quotes.currentPrice,
|
||||
h24Percent = value.quotes.h24ChangePercent,
|
||||
weekPercent = value.quotes.weekChangePercent,
|
||||
monthPercent = value.quotes.monthChangePercent,
|
||||
),
|
||||
imageUrl = getTokenIconUrlFromDefaultHost(tokenId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package com.tangem.features.feed.model.news.details.factory
|
||||
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.navigation.share.ShareManager
|
||||
import com.tangem.features.feed.ui.news.details.state.ArticleUM
|
||||
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
|
||||
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
|
||||
import com.tangem.utils.Provider
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class NewsDetailsStateFactory(
|
||||
private val currentStateProvider: Provider<NewsDetailsUM>,
|
||||
private val shareManager: ShareManager,
|
||||
private val onStateUpdate: (NewsDetailsUM) -> Unit,
|
||||
) {
|
||||
|
||||
fun updateArticles(articles: List<ArticleUM>, selectedIndex: Int) {
|
||||
val currentState = currentStateProvider()
|
||||
val currentArticle = articles.getOrNull(selectedIndex)
|
||||
onStateUpdate(
|
||||
currentState.copy(
|
||||
articles = articles.toImmutableList(),
|
||||
selectedArticleIndex = selectedIndex,
|
||||
onShareClick = {
|
||||
currentArticle?.let {
|
||||
shareManager.shareText(it.newsUrl)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSelectedArticleIndex(newIndex: Int) {
|
||||
val currentState = currentStateProvider()
|
||||
val currentArticle = currentState.articles.getOrNull(newIndex)
|
||||
onStateUpdate(
|
||||
currentState.copy(
|
||||
selectedArticleIndex = newIndex,
|
||||
onShareClick = {
|
||||
currentArticle?.let {
|
||||
shareManager.shareText(it.newsUrl)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateRelatedTokens(relatedTokens: RelatedTokensUM) {
|
||||
val currentState = currentStateProvider()
|
||||
onStateUpdate(currentState.copy(relatedTokensUM = relatedTokens))
|
||||
}
|
||||
|
||||
fun createRelatedTokensContent(
|
||||
items: List<MarketsListItemUM>,
|
||||
onTokenClick: (MarketsListItemUM) -> Unit,
|
||||
): RelatedTokensUM.Content {
|
||||
return RelatedTokensUM.Content(
|
||||
items = items.toImmutableList(),
|
||||
onTokenClick = onTokenClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,19 +16,23 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.common.ui.news.ArticleHeader
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SecondaryButtonIconStart
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.SpacerHMax
|
||||
import com.tangem.core.ui.components.SpacerW
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
|
||||
import com.tangem.core.ui.components.pager.PagerIndicator
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
|
|
@ -36,10 +40,8 @@ import com.tangem.core.ui.extensions.stringResourceSafe
|
|||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.features.feed.ui.news.details.state.ArticleUM
|
||||
import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory
|
||||
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
|
||||
import com.tangem.features.feed.ui.news.details.state.SourceUM
|
||||
import com.tangem.features.feed.ui.news.details.components.RelatedTokensBlock
|
||||
import com.tangem.features.feed.ui.news.details.state.*
|
||||
|
||||
@Composable
|
||||
internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) {
|
||||
|
|
@ -49,18 +51,19 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
|
|||
pageCount = { state.articles.size },
|
||||
)
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
.collect { page ->
|
||||
state.onArticleIndexChanged(page)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.selectedArticleIndex) {
|
||||
if (pagerState.currentPage != state.selectedArticleIndex) {
|
||||
pagerState.scrollToPage(state.selectedArticleIndex)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
if (pagerState.currentPage != state.selectedArticleIndex) {
|
||||
state.onArticleIndexChanged(pagerState.currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -76,6 +79,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
|
|||
article = state.articles[page],
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onLikeClick = state.onLikeClick,
|
||||
relatedTokensUM = state.relatedTokensUM,
|
||||
)
|
||||
}
|
||||
if (state.articles.size > 1) {
|
||||
|
|
@ -93,13 +97,18 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
|
|||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onLikeClick: () -> Unit) {
|
||||
private fun ArticleDetail(
|
||||
article: ArticleUM,
|
||||
onLikeClick: () -> Unit,
|
||||
relatedTokensUM: RelatedTokensUM,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(bottom = 56.dp + WindowInsets.navigationBars.getBottom(density).dp),
|
||||
) {
|
||||
item {
|
||||
item("content") {
|
||||
ArticleHeader(
|
||||
title = article.title,
|
||||
createdAt = article.createdAt.resolveReference(),
|
||||
|
|
@ -138,7 +147,14 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL
|
|||
onClick = onLikeClick,
|
||||
)
|
||||
|
||||
// TODO [REDACTED_TASK_KEY] add related tokens block
|
||||
RelatedTokensBlock(
|
||||
relatedTokensUM = relatedTokensUM,
|
||||
onItemClick = when (relatedTokensUM) {
|
||||
is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick
|
||||
else -> null
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
if (article.sources.isNotEmpty()) {
|
||||
SpacerH(24.dp)
|
||||
|
|
@ -161,7 +177,7 @@ private fun ArticleDetail(article: ArticleUM, modifier: Modifier = Modifier, onL
|
|||
}
|
||||
|
||||
if (article.sources.isNotEmpty()) {
|
||||
item {
|
||||
item("sources") {
|
||||
LazyRow(
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
state = rememberLazyListState(),
|
||||
|
|
@ -227,47 +243,64 @@ private fun QuickRecap(content: String, modifier: Modifier = Modifier) {
|
|||
private fun SourceItem(source: SourceUM, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.widthIn(max = 216.dp)
|
||||
.heightIn(min = 132.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors.background.action,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.sizeIn(maxWidth = 256.dp, minHeight = 132.dp)
|
||||
.background(color = TangemTheme.colors.background.action, shape = RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = source.onClick)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_explore_16),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
SpacerW(4.dp)
|
||||
Text(
|
||||
text = source.source.name,
|
||||
style = TangemTheme.typography.caption1,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_explore_16),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
SpacerW(4.dp)
|
||||
Text(
|
||||
text = source.source.name,
|
||||
style = TangemTheme.typography.caption1,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
if (source.title.isNotEmpty()) {
|
||||
SpacerH(4.dp)
|
||||
Text(
|
||||
text = source.title,
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (source.imageUrl != null) {
|
||||
SubcomposeAsyncImage(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentScale = ContentScale.Crop,
|
||||
model = ImageRequest.Builder(context = LocalContext.current)
|
||||
.data(source.imageUrl)
|
||||
.crossfade(enable = false)
|
||||
.allowHardware(true)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.build(),
|
||||
loading = {
|
||||
RectangleShimmer(
|
||||
modifier = Modifier.size(40.dp),
|
||||
radius = 4.dp,
|
||||
)
|
||||
},
|
||||
error = {},
|
||||
contentDescription = source.source.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (source.title.isNotEmpty()) {
|
||||
SpacerH(4.dp)
|
||||
Text(
|
||||
text = source.title,
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerHMax()
|
||||
|
||||
Text(
|
||||
text = source.publishedAt.resolveReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
|
|
@ -288,6 +321,7 @@ private fun PreviewNewsDetailsContent() {
|
|||
onShareClick = {},
|
||||
onLikeClick = {},
|
||||
onBackClick = {},
|
||||
onArticleIndexChanged = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
package com.tangem.features.feed.ui.news.details.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import com.tangem.common.ui.markets.MarketsListItem
|
||||
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.block.BlockCard
|
||||
import com.tangem.core.ui.components.block.TangemBlockCardColors
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.feed.model.news.details.NewsDetailsModel.Companion.RELATED_TOKEN_MAX_COUNT
|
||||
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
|
||||
|
||||
@Composable
|
||||
internal fun RelatedTokensBlock(
|
||||
relatedTokensUM: RelatedTokensUM,
|
||||
onItemClick: ((MarketsListItemUM) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isVisible = remember(relatedTokensUM) {
|
||||
when (relatedTokensUM) {
|
||||
is RelatedTokensUM.Content -> relatedTokensUM.items.isNotEmpty()
|
||||
RelatedTokensUM.Loading -> true
|
||||
RelatedTokensUM.LoadingError -> false
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVisible) return
|
||||
|
||||
Column(modifier = modifier) {
|
||||
SpacerH(40.dp)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.news_related_tokens),
|
||||
style = TangemTheme.typography.h3,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
SpacerH(12.dp)
|
||||
|
||||
BlockCard(
|
||||
colors = TangemBlockCardColors.copy(
|
||||
containerColor = TangemTheme.colors.background.action,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
when (relatedTokensUM) {
|
||||
is RelatedTokensUM.Content -> {
|
||||
relatedTokensUM.items.fastForEach { marketsListItemUM ->
|
||||
MarketsListItem(
|
||||
model = marketsListItemUM,
|
||||
onClick = { onItemClick?.invoke(marketsListItemUM) },
|
||||
)
|
||||
}
|
||||
}
|
||||
RelatedTokensUM.Loading -> {
|
||||
repeat(RELATED_TOKEN_MAX_COUNT) {
|
||||
MarketsListItemPlaceholder()
|
||||
}
|
||||
}
|
||||
RelatedTokensUM.LoadingError -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ internal object MockArticlesFactory {
|
|||
publishedAt = TextReference.Str("1h ago"),
|
||||
url = "https://cointelegraph.com",
|
||||
onClick = {},
|
||||
imageUrl = null,
|
||||
),
|
||||
SourceUM(
|
||||
id = 2,
|
||||
|
|
@ -42,9 +43,11 @@ internal object MockArticlesFactory {
|
|||
publishedAt = TextReference.Str("2h ago"),
|
||||
url = "https://investing.com",
|
||||
onClick = {},
|
||||
imageUrl = null,
|
||||
),
|
||||
).toPersistentList(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 2,
|
||||
|
|
@ -68,9 +71,11 @@ internal object MockArticlesFactory {
|
|||
publishedAt = TextReference.Str("3h ago"),
|
||||
url = "https://bloomberg.com",
|
||||
onClick = {},
|
||||
imageUrl = null,
|
||||
),
|
||||
).toPersistentList(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 3,
|
||||
|
|
@ -94,9 +99,11 @@ internal object MockArticlesFactory {
|
|||
publishedAt = TextReference.Str("5h ago"),
|
||||
url = "https://coindesk.com",
|
||||
onClick = {},
|
||||
imageUrl = null,
|
||||
),
|
||||
).toPersistentList(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 4,
|
||||
|
|
@ -120,9 +127,11 @@ internal object MockArticlesFactory {
|
|||
publishedAt = TextReference.Str("7h ago"),
|
||||
url = "https://theblock.co",
|
||||
onClick = {},
|
||||
imageUrl = null,
|
||||
),
|
||||
).toPersistentList(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 5,
|
||||
|
|
@ -136,6 +145,7 @@ internal object MockArticlesFactory {
|
|||
content = "A newly launched protocol unveiled innovative yield farming mechanism.\n\nAPY rates range from 15% to 30%.",
|
||||
sources = persistentListOf(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 6,
|
||||
|
|
@ -150,6 +160,7 @@ internal object MockArticlesFactory {
|
|||
content = "The US Senate Banking Committee advanced landmark crypto regulation bill.\n\nKey provisions include asset definitions.",
|
||||
sources = persistentListOf(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 7,
|
||||
|
|
@ -163,6 +174,7 @@ internal object MockArticlesFactory {
|
|||
content = "Major financial institution announced comprehensive crypto custody services.\n\nSupporting Bitcoin and Ethereum initially.",
|
||||
sources = persistentListOf(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 8,
|
||||
|
|
@ -176,6 +188,7 @@ internal object MockArticlesFactory {
|
|||
content = "Prominent NFT marketplace reported 300% increase in trading volume.\n\nNew features include lower fees.",
|
||||
sources = persistentListOf(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 9,
|
||||
|
|
@ -190,6 +203,7 @@ internal object MockArticlesFactory {
|
|||
content = "Layer 2 solution processed 100,000 transactions per second.\n\nUsing zero-knowledge proof technology.",
|
||||
sources = persistentListOf(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
ArticleUM(
|
||||
id = 10,
|
||||
|
|
@ -204,6 +218,7 @@ internal object MockArticlesFactory {
|
|||
content = "Stablecoin market cap reached \$180 billion all-time high.\n\nDriven by DeFi activity and institutional adoption.",
|
||||
sources = persistentListOf(),
|
||||
newsUrl = "",
|
||||
relatedTokens = persistentListOf(),
|
||||
),
|
||||
).toPersistentList()
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
package com.tangem.features.feed.ui.news.details.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.ui.components.label.entity.LabelUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.news.RelatedToken
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
internal data class NewsDetailsUM(
|
||||
|
|
@ -10,7 +13,8 @@ internal data class NewsDetailsUM(
|
|||
val onShareClick: () -> Unit,
|
||||
val onLikeClick: () -> Unit,
|
||||
val onBackClick: () -> Unit,
|
||||
val onArticleIndexChanged: (Int) -> Unit = {},
|
||||
val onArticleIndexChanged: (Int) -> Unit,
|
||||
val relatedTokensUM: RelatedTokensUM = RelatedTokensUM.Loading,
|
||||
)
|
||||
|
||||
internal data class ArticleUM(
|
||||
|
|
@ -23,6 +27,7 @@ internal data class ArticleUM(
|
|||
val content: String,
|
||||
val sources: ImmutableList<SourceUM>,
|
||||
val newsUrl: String,
|
||||
val relatedTokens: ImmutableList<RelatedToken>,
|
||||
)
|
||||
|
||||
internal data class SourceUM(
|
||||
|
|
@ -32,8 +37,22 @@ internal data class SourceUM(
|
|||
val publishedAt: TextReference,
|
||||
val url: String,
|
||||
val onClick: () -> Unit,
|
||||
val imageUrl: String?,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal sealed interface RelatedTokensUM {
|
||||
|
||||
data class Content(
|
||||
val items: ImmutableList<MarketsListItemUM>,
|
||||
val onTokenClick: (MarketsListItemUM) -> Unit,
|
||||
) : RelatedTokensUM
|
||||
|
||||
data object Loading : RelatedTokensUM
|
||||
|
||||
data object LoadingError : RelatedTokensUM
|
||||
}
|
||||
|
||||
internal data class Source(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue