Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-15 13:53:26 +05:00
parent 9f4dae9fa1
commit 236794fa00
26 changed files with 329 additions and 16 deletions

View file

@ -1,6 +1,9 @@
package com.tangem.features.feed.entry.components
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.domain.markets.TokenMarketParams
import kotlinx.serialization.Serializable
@ -13,6 +16,9 @@ sealed interface FeedEntryRoute {
val appCurrency: AppCurrency,
val shouldShowPortfolio: Boolean,
val analyticsParams: AnalyticsParams? = null,
val preselectedSection: PreselectedTokenDetailsSection? = null,
val shouldOpenExchanges: Boolean = false,
val exchangesCount: Int? = null,
) : FeedEntryRoute {
@Serializable
@ -23,7 +29,10 @@ sealed interface FeedEntryRoute {
}
@Serializable
data object MarketTokenList : FeedEntryRoute
data class MarketTokenList(
val preselectedOrder: PreselectedMarketsOrder? = null,
val preselectedInterval: PreselectedMarketsInterval? = null,
) : FeedEntryRoute
@Serializable
data class NewsDetail(val articleId: Int, val preselectedArticlesId: List<Int>) : FeedEntryRoute

View file

@ -3,6 +3,6 @@ package com.tangem.features.feed.entry.deeplink
interface MarketsDeepLinkHandler {
interface Factory {
fun create(): MarketsDeepLinkHandler
fun create(params: Map<String, String>): MarketsDeepLinkHandler
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.feed.entry.deeplink
import kotlinx.coroutines.CoroutineScope
interface MarketsTokenExchangesDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, params: Map<String, String>): MarketsTokenExchangesDeepLinkHandler
}
}

View file

@ -29,6 +29,9 @@ import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.model.FeedEntryModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.EntryContent
import dagger.assisted.Assisted
@ -87,6 +90,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
route = FeedEntryChildFactory.Child.TokenList(
params = DefaultMarketsTokenListComponent.Params(
preselectedSortType = sortBy ?: SortByTypeUM.Rating,
preselectedInterval = MarketsListUM.TrendInterval.H24,
shouldAlwaysShowSearchBar = sortBy == null,
),
),
@ -231,11 +235,15 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
paginationConfig = null,
)
},
preselectedSection = entryRoute.preselectedSection,
shouldOpenExchanges = entryRoute.shouldOpenExchanges,
exchangesCount = entryRoute.exchangesCount,
),
)
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
is FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
DefaultMarketsTokenListComponent.Params(
preselectedSortType = SortByTypeUM.Rating,
preselectedSortType = mapOrderToSortType(entryRoute.preselectedOrder),
preselectedInterval = mapIntervalToTrendInterval(entryRoute.preselectedInterval),
shouldAlwaysShowSearchBar = false,
),
)
@ -265,4 +273,24 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
}
private fun mapOrderToSortType(order: PreselectedMarketsOrder?): SortByTypeUM {
return when (order) {
PreselectedMarketsOrder.Rating -> SortByTypeUM.Rating
PreselectedMarketsOrder.Trending -> SortByTypeUM.Trending
PreselectedMarketsOrder.Buyers -> SortByTypeUM.ExperiencedBuyers
PreselectedMarketsOrder.Gainers -> SortByTypeUM.TopGainers
PreselectedMarketsOrder.Losers -> SortByTypeUM.TopLosers
null -> SortByTypeUM.Rating
}
}
private fun mapIntervalToTrendInterval(interval: PreselectedMarketsInterval?): MarketsListUM.TrendInterval {
return when (interval) {
PreselectedMarketsInterval.H24 -> MarketsListUM.TrendInterval.H24
PreselectedMarketsInterval.W1 -> MarketsListUM.TrendInterval.D7
PreselectedMarketsInterval.D30 -> MarketsListUM.TrendInterval.M1
null -> MarketsListUM.TrendInterval.H24
}
}
internal interface FeedEntryClickIntents : FeedModelClickIntents

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
@ -228,6 +229,9 @@ internal class DefaultMarketsTokenDetailsComponent(
val analyticsParams: AnalyticsParams?,
val onBackClicked: () -> Unit,
val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit,
val preselectedSection: PreselectedTokenDetailsSection? = null,
val shouldOpenExchanges: Boolean = false,
val exchangesCount: Int? = null,
)
@Serializable

View file

@ -29,6 +29,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.market.list.MarketsList
@ -127,6 +128,7 @@ internal class DefaultMarketsTokenListComponent(
@Serializable
data class Params(
val preselectedSortType: SortByTypeUM,
val preselectedInterval: MarketsListUM.TrendInterval,
val shouldAlwaysShowSearchBar: Boolean,
)

View file

@ -2,20 +2,31 @@ package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.INTERVAL_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.ORDER_KEY
import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultMarketsDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
appRouter: AppRouter,
) : MarketsDeepLinkHandler {
init {
appRouter.push(AppRoute.Markets)
appRouter.push(
AppRoute.Markets(
preselectedOrder = PreselectedMarketsOrder.parse(queryParams[ORDER_KEY]),
preselectedInterval = PreselectedMarketsInterval.parse(queryParams[INTERVAL_KEY]),
),
)
}
@AssistedFactory
interface Factory : MarketsDeepLinkHandler.Factory {
override fun create(): DefaultMarketsDeepLinkHandler
override fun create(params: Map<String, String>): DefaultMarketsDeepLinkHandler
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.features.feed.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.SECTION_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -11,12 +13,12 @@ import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@ -32,8 +34,15 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc
private fun handleDeepLink() {
val tokenId = queryParams[TOKEN_ID_KEY]
val section = PreselectedTokenDetailsSection.parse(queryParams[SECTION_KEY])
val rawTokenId = CryptoCurrency.RawID(tokenId.orEmpty())
if (tokenId.isNullOrEmpty()) {
TangemLogger.e("Markets token details deeplink does not contain token_id")
appRouter.push(AppRoute.Markets())
return
}
val rawTokenId = CryptoCurrency.RawID(tokenId)
scope.launch {
val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse {
@ -65,6 +74,7 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc
appCurrency = appCurrency,
shouldShowPortfolio = true,
analyticsParams = null,
preselectedSection = section,
),
)
}

View file

@ -0,0 +1,88 @@
package com.tangem.features.feed.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal class DefaultMarketsTokenExchangesDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
) : MarketsTokenExchangesDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
val tokenId = queryParams[TOKEN_ID_KEY]
if (tokenId.isNullOrEmpty()) {
TangemLogger.e("Markets token exchanges deeplink does not contain token_id")
appRouter.push(AppRoute.Markets())
return
}
val rawTokenId = CryptoCurrency.RawID(tokenId)
scope.launch {
val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse {
AppCurrency.Default
}
val tokenInfo = getTokenMarketInfoUseCase(
appCurrency = appCurrency,
tokenId = rawTokenId,
tokenSymbol = "",
).getOrElse {
TangemLogger.e("Failed to get market token info for exchanges deeplink")
appRouter.push(AppRoute.Markets())
return@launch
}
appRouter.push(
AppRoute.MarketsTokenDetails(
token = TokenMarketParams(
id = rawTokenId,
name = tokenInfo.name,
symbol = tokenInfo.symbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = tokenInfo.quotes.currentPrice,
h24Percent = tokenInfo.quotes.h24ChangePercent,
weekPercent = tokenInfo.quotes.weekChangePercent,
monthPercent = tokenInfo.quotes.monthChangePercent,
),
imageUrl = getTokenIconUrlFromDefaultHost(rawTokenId),
),
appCurrency = appCurrency,
shouldShowPortfolio = true,
shouldOpenExchanges = true,
exchangesCount = tokenInfo.exchangesAmount,
),
)
}
}
@AssistedFactory
interface Factory : MarketsTokenExchangesDeepLinkHandler.Factory {
override fun create(
coroutineScope: CoroutineScope,
queryParams: Map<String, String>,
): DefaultMarketsTokenExchangesDeepLinkHandler
}
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.feed.deeplink.di
import com.tangem.features.feed.deeplink.DefaultMarketsDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultMarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultMarketsTokenExchangesDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -23,4 +25,10 @@ internal interface MarketsDeepLinkModule {
fun bindMarketsTokenDetailDeepLinkHandlerFactory(
impl: DefaultMarketsTokenDetailDeepLinkHandler.Factory,
): MarketsTokenDetailDeepLinkHandler.Factory
@Binds
@Singleton
fun bindMarketsTokenExchangesDeepLinkHandlerFactory(
impl: DefaultMarketsTokenExchangesDeepLinkHandler.Factory,
): MarketsTokenExchangesDeepLinkHandler.Factory
}

View file

@ -5,6 +5,7 @@ import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.TangemSiteShareUrlBuilder
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.sorted
@ -21,6 +22,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
@ -95,6 +97,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val quotesJob = JobHolder()
private var userCountry: UserCountry? = null
private var isScrollToSectionHandled = false
private val params = paramsContainer.require<DefaultMarketsTokenDetailsComponent.Params>()
private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token)
@ -297,6 +300,15 @@ internal class MarketsTokenDetailsModel @Inject constructor(
initialLoad()
loadRelatedNews()
if (params.shouldOpenExchanges) {
modelScope.launch {
val exchangesCount = params.exchangesCount
?: currentTokenInfo.value?.exchangesAmount
?: 0
onListedOnClick(exchangesCount)
}
}
}
private fun initialLoad() {
@ -519,6 +531,14 @@ internal class MarketsTokenDetailsModel @Inject constructor(
description = descriptionConverter.convert(newInfo),
infoBlocks = infoConverter.convert(newInfo),
),
scrollToSection = if (!isScrollToSectionHandled) {
mapSectionToKey(params.preselectedSection)?.let { key ->
isScrollToSectionHandled = true
triggeredEvent(data = key, onConsume = ::consumeScrollToSection)
} ?: consumedEvent()
} else {
marketsTokenDetailsUM.scrollToSection
},
)
}
@ -755,6 +775,17 @@ internal class MarketsTokenDetailsModel @Inject constructor(
)
}
private fun mapSectionToKey(section: PreselectedTokenDetailsSection?): String? {
return when (section) {
PreselectedTokenDetailsSection.News -> MarketsTokenDetailsUM.RelatedNews.SECTION_KEY
null -> null
}
}
private fun consumeScrollToSection() {
state.update { it.copy(scrollToSection = consumedEvent()) }
}
private companion object {
const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L
const val RELATED_NEWS_LIMIT = 10

View file

@ -67,6 +67,7 @@ internal class MarketsListModel @Inject constructor(
onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) },
shouldAlwaysShowSearchBar = Provider { modelParams.params.shouldAlwaysShowSearchBar },
preselectedSortType = Provider { modelParams.params.preselectedSortType },
preselectedInterval = Provider { modelParams.params.preselectedInterval },
onBackClick = modelParams.clickIntents.onBackClicked,
analyticsEventHandler = analyticsEventHandler,
onSearchBarClick = modelParams.clickIntents.onSearchClicked,

View file

@ -27,6 +27,7 @@ internal class MarketsListUMStateManager(
private val shouldAlwaysShowSearchBar: Provider<Boolean>,
private val currentVisibleIds: Provider<List<CryptoCurrency.RawID>>,
private val preselectedSortType: Provider<SortByTypeUM>,
private val preselectedInterval: Provider<MarketsListUM.TrendInterval>,
private val onLoadMoreUiItems: () -> Unit,
private val visibleItemsChanged: (itemsKeys: List<CryptoCurrency.RawID>) -> Unit,
private val onRetryButtonClicked: () -> Unit,
@ -229,7 +230,7 @@ internal class MarketsListUMStateManager(
shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(),
),
selectedSortBy = preselectedSortType(),
selectedInterval = MarketsListUM.TrendInterval.H24,
selectedInterval = preselectedInterval(),
onIntervalClick = { selectedInterval = it },
onSortByButtonClick = { isSortByBottomSheetShown = true },
sortByBottomSheet = TangemBottomSheetConfig(

View file

@ -106,6 +106,13 @@ private fun Content(
lazyListState = lazyListState,
onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange,
)
EventEffect(state.scrollToSection) { targetKey ->
val targetIndex = lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == targetKey }?.index
if (targetIndex != null) {
lazyListState.animateScrollToItem(targetIndex)
}
}
var bottomSpacing by remember { mutableStateOf(0.dp) }
Box(

View file

@ -76,6 +76,8 @@ private fun LazyListScope.tokenMarketDetailsBodyV1(
if (relatedNews.articles.isNotEmpty()) {
relatedNews(relatedNews)
} else {
sectionStub(RelatedNews.SECTION_KEY)
}
aboutCoinHeader()
@ -91,6 +93,11 @@ private fun LazyListScope.tokenMarketDetailsBodyV1(
}
}
// Empty item with a key so that deeplink scroll-to-section can target it before the real content is composed
private fun LazyListScope.sectionStub(key: String) {
item(key) { }
}
@Suppress("CanBeNonNullable")
private fun LazyListScope.tokenMarketDetailsBodyV2(state: MarketsTokenDetailsUM.Body, relatedNews: RelatedNews) {
when (state) {
@ -244,6 +251,8 @@ internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.Informa
if (relatedNews.articles.isNotEmpty()) {
relatedNews(relatedNews)
} else {
sectionStub(RelatedNews.SECTION_KEY)
}
if (state.securityScore != null) {
@ -324,7 +333,7 @@ private fun LazyListScope.loadingInfoBlocksV2() {
}
private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
item("related-news") {
item(RelatedNews.SECTION_KEY) {
Column(
modifier = Modifier
.fillMaxWidth()

View file

@ -5,6 +5,7 @@ import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
@ -30,6 +31,7 @@ internal data class MarketsTokenDetailsUM(
val onShouldShowPriceSubtitleChange: (Boolean) -> Unit,
val relatedNews: RelatedNews,
val onShareClick: () -> Unit,
val scrollToSection: StateEvent<String> = consumedEvent(),
) {
data class ChartState(
@ -80,5 +82,9 @@ internal data class MarketsTokenDetailsUM(
val onArticledClicked: (id: Int) -> Unit,
val onFirstVisible: () -> Unit,
val onScroll: () -> Unit,
)
) {
companion object {
const val SECTION_KEY = "related-news"
}
}
}