Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-18 12:40:50 +01:00
parent b449775ad8
commit 078e8ed336
25 changed files with 2184 additions and 48 deletions

View file

@ -21,10 +21,11 @@ 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.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
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 com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -60,7 +61,16 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
override fun onMarketOpenClick(sortBy: SortByTypeUM) {
innerRouter.push(FeedEntryChildFactory.Child.TokenList)
innerRouter.push(
route = FeedEntryChildFactory.Child.TokenList(
params = DefaultMarketsTokenListComponent.Params(
onBackClicked = { onChildBack() },
onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
preselectedSortType = sortBy,
shouldAlwaysShowSearchBar = sortBy == SortByTypeUM.Rating,
),
),
)
}
override fun onArticleClick(articleId: Int) {

View file

@ -24,7 +24,7 @@ internal class FeedEntryChildFactory @Inject constructor() {
@Serializable
@Immutable
data object TokenList : Child
data class TokenList(val params: DefaultMarketsTokenListComponent.Params) : Child
@Serializable
@Immutable
@ -54,12 +54,7 @@ internal class FeedEntryChildFactory @Inject constructor() {
is Child.TokenList -> {
DefaultMarketsTokenListComponent(
appComponentContext = appComponentContext,
onTokenClick = { token, appCurrency ->
feedEntryClickIntents.onMarketItemClick(
token,
appCurrency,
)
},
params = child.params,
)
}
Child.NewsDetails -> {

View file

@ -1,27 +1,57 @@
package com.tangem.features.feed.components.market.list
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.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.ui.market.list.MarketsList
import com.tangem.features.feed.ui.market.list.TopBarWithSearch
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import kotlinx.serialization.Serializable
@Suppress("UnusedPrivateProperty") // TODO will be remove in next PR
internal class DefaultMarketsTokenListComponent(
appComponentContext: AppComponentContext,
private val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)? = null,
private val params: Params,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: MarketsListModel = getOrCreateModel<MarketsListModel, Params>(params = params)
@Composable
override fun Title() {
val state by model.state.collectAsStateWithLifecycle()
TopBarWithSearch(state.searchBar)
}
@Composable
override fun Content(modifier: Modifier) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
MarketsList(
modifier = modifier,
state = state,
)
}
@Composable
override fun Footer() {
}
override fun Footer() = Unit
@Serializable
data class Params(
val onBackClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val preselectedSortType: SortByTypeUM,
val shouldAlwaysShowSearchBar: Boolean,
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.feed.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.market.list.MarketsListModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -17,4 +18,9 @@ internal interface ModelModule {
@IntoMap
@ClassKey(FeedComponentModel::class)
fun bindsFeedComponentModel(model: FeedComponentModel): Model
@Binds
@IntoMap
@ClassKey(MarketsListModel::class)
fun provideMarketsListModel(model: MarketsListModel): Model
}

View file

@ -12,7 +12,7 @@ import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal

View file

@ -18,7 +18,7 @@ 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
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
@ -31,6 +31,7 @@ import kotlinx.coroutines.launch
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import javax.inject.Inject
import kotlin.collections.all
@Stable
@ModelScoped
@ -141,7 +142,9 @@ internal class FeedComponentModel @Inject constructor(
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
onActiveChange = {
if (it) params.feedClickIntents.onMarketOpenClick(SortByTypeUM.Rating)
},
),
feedListCallbacks = FeedListCallbacks(
onSearchClick = {},

View file

@ -2,7 +2,7 @@ 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
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
/**
* Callback interface for feed model navigation actions.

View file

@ -0,0 +1,329 @@
package com.tangem.features.feed.model.market.list
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.ShouldShowYieldModeMarketPromoUseCase
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.promo.PromoRepository
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.model.market.list.analytics.MarketsListAnalyticsEvent
import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager
import com.tangem.features.feed.model.market.list.statemanager.MarketsListUMStateManager
import com.tangem.features.feed.ui.market.list.state.ListUM
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.MarketsNotificationUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import javax.inject.Inject
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
private const val SEARCH_QUERY_DEBOUNCE_MILLIS = 800L
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@ModelScoped
@Stable
@Suppress("LongParameterList", "PropertyUsedBeforeDeclaration")
internal class MarketsListModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
shouldShowYieldModeMarketPromoUseCase: ShouldShowYieldModeMarketPromoUseCase,
paramsContainer: ParamsContainer,
private val promoRepository: PromoRepository,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val updateQuotesJob = JobHolder()
private val params = paramsContainer.require<DefaultMarketsTokenListComponent.Params>()
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val visibleItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val marketsListUMStateManager by lazy {
MarketsListUMStateManager(
currentVisibleIds = Provider { visibleItemIds.value },
onLoadMoreUiItems = { activeListManager.loadMore() },
visibleItemsChanged = { visibleItemIds.value = it },
onRetryButtonClicked = { activeListManager.reload() },
onTokenClick = { onTokenUIClicked(it) },
onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) },
shouldAlwaysShowSearchBar = Provider { params.shouldAlwaysShowSearchBar },
preselectedSortType = Provider { params.preselectedSortType },
)
}
private val mainMarketsListManager by lazy {
MarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
currentAppCurrency = Provider { currentAppCurrency.value },
currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval },
currentSortByType = Provider { marketsListUMStateManager.selectedSortByType },
currentSearchText = Provider { null },
modelScope = modelScope,
dispatchers = dispatchers,
)
}
private val searchMarketsListManager by lazy {
MarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
currentAppCurrency = Provider { currentAppCurrency.value },
currentTrendInterval = Provider { marketsListUMStateManager.selectedInterval },
currentSortByType = Provider { SortByTypeUM.Rating },
currentSearchText = Provider { marketsListUMStateManager.searchQuery },
modelScope = modelScope,
dispatchers = dispatchers,
)
}
private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager
val isVisibleOnScreen = MutableStateFlow(false)
val state = marketsListUMStateManager.state.asStateFlow()
init {
modelScope.launch {
marketsListUMStateManager.isInSearchStateFlow.flatMapLatest { isInSearchMode ->
if (isInSearchMode) {
combine(
flow = searchMarketsListManager.uiItems,
flow2 = searchMarketsListManager.isInInitialLoadingErrorState,
flow3 = searchMarketsListManager.isSearchNotFoundState,
flow4 = shouldShowYieldModeMarketPromoUseCase(
appCurrency = currentAppCurrency.value,
interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(),
),
) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, isYieldModePromo ->
MarketsItemsData(
items = uiItems,
isInErrorState = isInInitialLoadingErrorState,
isSearchNotFound = isSearchNotFoundState,
shouldShowYieldModePromo = isYieldModePromo,
)
}
} else {
combine(
flow = mainMarketsListManager.uiItems,
flow2 = mainMarketsListManager.isInInitialLoadingErrorState,
flow3 = shouldShowYieldModeMarketPromoUseCase(
appCurrency = currentAppCurrency.value,
interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(),
),
) { uiItems, isInInitialLoadingErrorState, shouldShowYieldModePromo ->
MarketsItemsData(
items = uiItems,
isInErrorState = isInInitialLoadingErrorState,
isSearchNotFound = false,
shouldShowYieldModePromo = shouldShowYieldModePromo,
)
}
}
}.collect { marketsItemsData ->
val shouldShowYieldModePromo = marketsItemsData.shouldShowYieldModePromo
if (marketsListUMStateManager.state.value.marketsNotificationUM == null && shouldShowYieldModePromo) {
analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModePromoShown())
}
marketsListUMStateManager.onUiItemsChanged(
uiItems = marketsItemsData.items,
isInErrorState = marketsItemsData.isInErrorState,
isSearchNotFound = marketsItemsData.isSearchNotFound,
marketsNotificationUM = if (shouldShowYieldModePromo) {
MarketsNotificationUM.YieldSupplyPromo(
onClick = {
analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModeMoreInfoClicked())
marketsListUMStateManager.selectedSortByType = SortByTypeUM.YieldSupply
},
onCloseClick = { onYieldModeNotificationCloseClick() },
)
} else {
null
},
)
}
}
state.onEach { marketsListUM ->
if (marketsListUM.list !is ListUM.Content) {
visibleItemIds.value = emptyList()
}
}.launchIn(modelScope)
// update all lists when user's currency has changed
currentAppCurrency.drop(1).onEach {
mainMarketsListManager.reload()
if (marketsListUMStateManager.isInSearchState) {
searchMarketsListManager.reload()
}
}.launchIn(modelScope)
// load charts when new batch is being loaded
mainMarketsListManager.onLastBatchLoadedSuccess.onEach { batchKey ->
mainMarketsListManager.loadCharts(setOf(batchKey), marketsListUMStateManager.selectedInterval)
modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS)
}.launchIn(modelScope)
// listen currently selected interval, update charts if sorting=rating, or reload all list
modelScope.launch(dispatchers.default) {
marketsListUMStateManager.state.map { it.selectedInterval }.distinctUntilChanged().drop(1)
.collectLatest { interval ->
when (marketsListUMStateManager.selectedSortByType) {
SortByTypeUM.Rating -> {
mainMarketsListManager.updateUIWithSameState()
val batchKeys = mainMarketsListManager.getBatchKeysByItemIds(visibleItemIds.value)
mainMarketsListManager.loadCharts(batchKeys, interval)
}
else -> mainMarketsListManager.reload()
}
}
}
// reload list when sorting type has changed
modelScope.launch {
marketsListUMStateManager
.state
.map { it.selectedSortBy }
.distinctUntilChanged()
.drop(1)
.collectLatest { _ ->
mainMarketsListManager.reload()
}
}
// listen current visible batch and update charts
modelScope.launch {
visibleItemIds.mapNotNull { listOfIds ->
if (listOfIds.isNotEmpty()) {
activeListManager.getBatchKeysByItemIds(visibleItemIds.value)
} else {
null
}
}.distinctUntilChanged().collectLatest { visibleBatchKeys ->
// TODO load batch on scroll heat area
activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval)
}
}
// ===Search===
modelScope.launch {
marketsListUMStateManager.isInSearchStateFlow.collectLatest { isInSearchMode ->
activeListManager = if (isInSearchMode) {
searchMarketsListManager
} else {
searchMarketsListManager.clearStateAndStopAllActions()
mainMarketsListManager
}
}
}
modelScope.launch {
marketsListUMStateManager.searchQueryFlow.debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS)
.distinctUntilChanged().onEach {
if (it.isEmpty()) searchMarketsListManager.clearStateAndStopAllActions()
}.filter { it.isNotEmpty() && activeListManager == searchMarketsListManager }.collectLatest {
searchMarketsListManager.reload(searchText = it)
}
}
modelScope.launch {
searchMarketsListManager.onLastBatchLoadedSuccess.collectLatest { batchKey ->
searchMarketsListManager.loadCharts(setOf(batchKey), marketsListUMStateManager.selectedInterval)
modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS)
}
}
searchMarketsListManager
.isSearchNotFoundState
.onEach { isTokenFound ->
if (isTokenFound) {
analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = false))
}
}.launchIn(modelScope)
searchMarketsListManager.onFirstBatchLoadedSuccess.onEach {
analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(isTokenFound = true))
}.launchIn(modelScope)
// analytics
initAnalytics()
// initial loading
mainMarketsListManager.reload()
}
private fun MarketsListUM.TrendInterval.toBatchRequestInterval(): TokenMarketListConfig.Interval {
return when (this) {
MarketsListUM.TrendInterval.H24 -> TokenMarketListConfig.Interval.H24
MarketsListUM.TrendInterval.D7 -> TokenMarketListConfig.Interval.WEEK
MarketsListUM.TrendInterval.M1 -> TokenMarketListConfig.Interval.MONTH
}
}
private fun initAnalytics() {
state.filter { it.isInSearchMode.not() }
.map { MarketsListAnalyticsEvent.SortBy(it.selectedSortBy, it.selectedInterval) }.distinctUntilChanged()
.onEach {
analyticsEventHandler.send(it)
}.launchIn(modelScope)
}
private fun onTokenUIClicked(token: MarketsListItemUM) {
modelScope.launch {
activeListManager.getTokenById(token.id)?.let { found ->
params.onTokenClick(found.toSerializableParam(), currentAppCurrency.value)
}
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {
delay(timeMillis)
isVisibleOnScreen.first { it }
activeListManager.updateQuotes()
}
}.saveIn(updateQuotesJob)
}
private fun onYieldModeNotificationCloseClick() {
analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModePromoClosed())
modelScope.launch {
promoRepository.setMarketsYieldSupplyNotificationHideClicked()
}
}
private class MarketsItemsData(
val items: ImmutableList<MarketsListItemUM>,
val isInErrorState: Boolean,
val isSearchNotFound: Boolean,
val shouldShowYieldModePromo: Boolean,
)
}

View file

@ -0,0 +1,49 @@
package com.tangem.features.feed.model.market.list.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
internal sealed class MarketsListAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets", event = event, params = params) {
data class SortBy(
val sortByTypeUM: SortByTypeUM,
val interval: MarketsListUM.TrendInterval,
) : MarketsListAnalyticsEvent(
event = "Sort By",
params = mapOf(
"Type" to when (sortByTypeUM) {
SortByTypeUM.Rating -> "Rating"
SortByTypeUM.Trending -> "Trending"
SortByTypeUM.ExperiencedBuyers -> "Buyers"
SortByTypeUM.TopGainers -> "Gainers"
SortByTypeUM.TopLosers -> "Losers"
SortByTypeUM.Staking -> "Staking"
SortByTypeUM.YieldSupply -> "Yield Supply"
},
"Period" to when (interval) {
MarketsListUM.TrendInterval.H24 -> "24h"
MarketsListUM.TrendInterval.D7 -> "7d"
MarketsListUM.TrendInterval.M1 -> "1m"
},
),
)
class YieldModePromoShown : MarketsListAnalyticsEvent(event = "Notice - Yield Mode Promo")
class YieldModePromoClosed : MarketsListAnalyticsEvent(event = "Yield Mode Promo Closed")
class YieldModeMoreInfoClicked : MarketsListAnalyticsEvent(event = "Yield Mode More Info")
data class TokenSearched(val isTokenFound: Boolean) : MarketsListAnalyticsEvent(
event = "Token Searched",
params = mapOf(
"Result" to if (isTokenFound) "Yes" else "No",
),
)
class ShowTokens : MarketsListAnalyticsEvent(event = "Button - Show Tokens")
}

View file

@ -0,0 +1,372 @@
package com.tangem.features.feed.model.market.list.statemanager
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.model.market.list.utils.logAction
import com.tangem.features.feed.model.market.list.utils.logStatus
import com.tangem.features.feed.model.market.list.utils.logUpdateResults
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
private const val LOG_EVENTS = true
@Suppress("LongParameterList", "LargeClass")
internal class MarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
private val currentTrendInterval: Provider<MarketsListUM.TrendInterval>,
private val currentAppCurrency: Provider<AppCurrency>,
private val currentSearchText: Provider<String?>,
private val currentSortByType: Provider<SortByTypeUM>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
private val updateStateJob = JobHolder()
private val batchFlow = getMarketsTokenListFlowUseCase(
batchingContext = TokenListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
batchFlowType = batchFlowType,
)
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = resultBatches.map { it.uiBatches }
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
get() = uiBatches
.map { batches ->
batches.asSequence()
.map { it.data }
.flatten()
.toImmutableList()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = persistentListOf(),
)
val onLastBatchLoadedSuccess = batchFlow.state
.distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size }
.mapNotNull { batchListState ->
when (val status = batchListState.status) {
is PaginationStatus.Paginating -> {
if (status.lastResult is BatchFetchResult.Success) {
batchListState.data.lastOrNull()?.key
} else {
null
}
}
is PaginationStatus.EndOfPagination -> {
batchListState.data.lastOrNull()?.key
}
else -> null
}
}
val onFirstBatchLoadedSuccess = batchFlow.state
.distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size }
.mapNotNull { batchListState ->
when (val status = batchListState.status) {
is PaginationStatus.Paginating -> {
if (status.lastResult is BatchFetchResult.Success) {
batchListState.data.size == 1
} else {
null
}
}
is PaginationStatus.EndOfPagination -> {
batchListState.data.size == 1
}
else -> null
}
}
.filter { it }
val isInInitialLoadingErrorState = batchFlow.state
.map { it.status is PaginationStatus.InitialLoadingError }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
val isSearchNotFoundState = batchFlow.state
.map { batchListState ->
currentSearchText().isNullOrEmpty().not() &&
batchListState.status is PaginationStatus.EndOfPagination &&
batchListState.data.isEmpty()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size &&
a.map { it.key } == b.map { it.key } &&
a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEach {
coroutineScope {
launch {
updateState(it)
}.saveIn(updateStateJob)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
if (LOG_EVENTS) {
batchFlow.updateResults
.onEach { logUpdateResults(batchFlowType.name, it) }
.launchIn(modelScope)
batchFlow.state
.map { it.status }
.onEach { logStatus(batchFlowType.name, it) }
.launchIn(modelScope)
actionsFlow
.onEach { logAction(batchFlowType.name, it) }
.launchIn(modelScope)
}
}
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
withContext(dispatchers.default) {
resultBatches.update { resultBatches ->
val items = resultBatches.uiBatches
val previousList = resultBatches.processedItems
val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency())
if (newList.isEmpty()) {
return@update ResultBatches(processedItems = emptyList())
}
val isInitialLoading =
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
val outItems = if (isInitialLoading) {
newList.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
if (previousList.size != newList.size) {
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = newList.filter { keysToAdd.contains(it.key) }
items + newBatches.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (previousList == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data[index]
val newItem = newBatch.data[index]
converter.update(
prevItem,
marketsListItemUM,
newItem,
)
},
)
}
}
}
currentCoroutineContext().ensureActive()
ResultBatches(
uiBatches = outItems,
processedItems = newList,
)
}
}
fun reload(searchText: String? = null) {
modelScope.launch {
resultBatches.value = ResultBatches()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
fiatPriceCurrency = currentAppCurrency().code,
searchText = if (currentSearchText() == null) {
null
} else {
searchText ?: currentSearchText()
},
priceChangeInterval = currentTrendInterval().toBatchRequestInterval(),
order = currentSortByType().toRequestOrder(),
),
),
)
}
}
fun loadMore() {
modelScope.launch {
actionsFlow.emit(BatchAction.LoadMore())
}
}
fun updateUIWithSameState() {
modelScope.launch(dispatchers.default) {
val current = batchFlow.state.value.data
updateState(current, forceUpdate = true)
}.saveIn(updateStateJob)
}
fun loadCharts(batchKeys: Set<Int>, interval: MarketsListUM.TrendInterval) {
if (batchKeys.isEmpty()) return
modelScope.launch {
val currentData = batchFlow.state.value.data
val alreadyLoadedChartsBatchKeys = currentData
.filter { batch ->
val first = batch.data.firstOrNull() ?: return@filter false
val chartByInterval = when (interval) {
MarketsListUM.TrendInterval.H24 -> first.tokenCharts.h24
MarketsListUM.TrendInterval.D7 -> first.tokenCharts.week
MarketsListUM.TrendInterval.M1 -> first.tokenCharts.month
}
chartByInterval != null
}
.map { it.key }
.toSet()
val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys)
if (batchesKeysToLoad.isNotEmpty()) {
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchesKeysToLoad,
updateRequest = TokenMarketUpdateRequest.UpdateChart(
interval = interval.toBatchRequestInterval(),
currency = currentAppCurrency().code,
),
async = true,
operationId = batchesKeysToLoad.toString() + interval.toString(),
),
)
}
}
}
fun updateQuotes() {
modelScope.launch {
actionsFlow.emit(
BatchAction.CancelUpdates {
it.updateRequest is TokenMarketUpdateRequest.UpdateQuotes
},
)
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchFlow
.state
.value
.data
.map { it.key }
.toSet(),
updateRequest = TokenMarketUpdateRequest.UpdateQuotes(
currencyId = currentAppCurrency().code,
),
async = true,
operationId = "update quotes",
),
)
}
}
fun clearStateAndStopAllActions() {
resultBatches.value = ResultBatches()
modelScope.launch {
actionsFlow.emit(BatchAction.Reset)
}
}
fun getBatchKeysByItemIds(ids: List<CryptoCurrency.RawID>): Set<Int> {
val currentData = batchFlow.state.value.data
return currentData
.filter { d -> d.data.any { ids.contains(it.id) } }
.map { it.key }
.toSet()
}
fun getTokenById(id: CryptoCurrency.RawID): TokenMarket? {
return batchFlow
.state
.value
.data
.map { it.data }
.flatten()
.find { it.id == id }
}
private fun SortByTypeUM.toRequestOrder(): TokenMarketListConfig.Order {
return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating
SortByTypeUM.Trending -> TokenMarketListConfig.Order.Trending
SortByTypeUM.ExperiencedBuyers -> TokenMarketListConfig.Order.Buyers
SortByTypeUM.TopGainers -> TokenMarketListConfig.Order.TopGainers
SortByTypeUM.TopLosers -> TokenMarketListConfig.Order.TopLosers
SortByTypeUM.Staking -> TokenMarketListConfig.Order.Staking
SortByTypeUM.YieldSupply -> TokenMarketListConfig.Order.YieldSupply
}
}
private fun MarketsListUM.TrendInterval.toBatchRequestInterval(): TokenMarketListConfig.Interval {
return when (this) {
MarketsListUM.TrendInterval.H24 -> TokenMarketListConfig.Interval.H24
MarketsListUM.TrendInterval.D7 -> TokenMarketListConfig.Interval.WEEK
MarketsListUM.TrendInterval.M1 -> TokenMarketListConfig.Interval.MONTH
}
}
private data class ResultBatches(
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
}

View file

@ -0,0 +1,267 @@
package com.tangem.features.feed.model.market.list.statemanager
import androidx.compose.runtime.Stable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.list.state.*
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
@Stable
@Suppress("LongParameterList", "LargeClass")
internal class MarketsListUMStateManager(
private val shouldAlwaysShowSearchBar: Provider<Boolean>,
private val currentVisibleIds: Provider<List<CryptoCurrency.RawID>>,
private val preselectedSortType: Provider<SortByTypeUM>,
private val onLoadMoreUiItems: () -> Unit,
private val visibleItemsChanged: (itemsKeys: List<CryptoCurrency.RawID>) -> Unit,
private val onRetryButtonClicked: () -> Unit,
private val onTokenClick: (MarketsListItemUM) -> Unit,
private val onShowTokensUnder100kClicked: () -> Unit,
) {
val state = MutableStateFlow(state())
private var isSortByBottomSheetShown
get() = state.value.sortByBottomSheet.isShown
set(value) = state.update { it.copy(sortByBottomSheet = it.sortByBottomSheet.copy(isShown = value)) }
var searchQuery
get() = state.value.searchBar.query
private set(value) = state.update { marketsListUM ->
marketsListUM.copy(
searchBar = marketsListUM.searchBar.copy(
query = value,
isActive = value.isNotEmpty(),
),
)
}
var isInSearchState
get() = state.value.searchBar.isActive
private set(value) = state.update { it.copy(searchBar = it.searchBar.copy(isActive = value)) }
var selectedSortByType
get() = state.value.selectedSortBy
set(value) = state.update { marketsListUM ->
marketsListUM.copy(
selectedSortBy = value,
sortByBottomSheet = marketsListUM.sortByBottomSheet.copy(
content = (marketsListUM.sortByBottomSheet.content as SortByBottomSheetContentUM).copy(
selectedOption = value,
),
),
list = if (marketsListUM.list is ListUM.Content && marketsListUM.selectedSortBy != value) {
marketsListUM.list.copy(
triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() },
)
} else {
marketsListUM.list
},
)
}
var selectedInterval
get() = state.value.selectedInterval
set(value) = state.update { marketsListUM ->
marketsListUM.copy(
selectedInterval = value,
list = if (marketsListUM.list is ListUM.Content &&
marketsListUM.selectedSortBy != SortByTypeUM.Rating &&
marketsListUM.selectedInterval != value
) {
marketsListUM.list.copy(
triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() },
)
} else {
marketsListUM.list
},
)
}
val isInSearchStateFlow = state.map { it.isInSearchMode }.distinctUntilChanged()
val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged()
fun onUiItemsChanged(
isInErrorState: Boolean,
isSearchNotFound: Boolean,
uiItems: ImmutableList<MarketsListItemUM>,
marketsNotificationUM: MarketsNotificationUM?,
) {
state.update { marketsListUM ->
when {
isInErrorState -> {
marketsListUM.copy(
list = ListUM.LoadingError(onRetryClicked = onRetryButtonClicked),
)
}
isSearchNotFound -> {
marketsListUM.copy(list = ListUM.SearchNothingFound)
}
uiItems.isEmpty() -> {
marketsListUM.copy(list = ListUM.Loading)
}
else -> {
marketsListUM.updateItems(
newItems = uiItems,
marketsNotificationUM = marketsNotificationUM,
)
}
}
}
}
private fun MarketsListUM.updateItems(
newItems: ImmutableList<MarketsListItemUM>,
marketsNotificationUM: MarketsNotificationUM?,
): MarketsListUM {
val currentState = this
if (isInSearchMode.not() || currentState.showUnder100kButtonAlreadyPressed()) {
val itemsWithFilteredPriceChange = newItems.filterPriceChangeByVisibility()
return currentState.copy(
list = generalContentState(itemsWithFilteredPriceChange)
.copy(
shouldShowUnder100kTokensNotificationWasHidden = currentState
.showUnder100kButtonAlreadyPressed(),
),
marketsNotificationUM = marketsNotificationUM,
)
}
// Search state cases
val filtered = newItems.filter { item -> item.isUnder100kMarketCap.not() }
.toImmutableList()
.filterPriceChangeByVisibility()
if (filtered.size != newItems.size) {
val searchUiItemsCached = newItems.filterPriceChangeByVisibility()
return currentState.copy(
list = generalContentState(filtered).copy(
shouldShowUnder100kTokensNotificationWasHidden = false,
shouldShowUnder100kTokensNotification = true,
onShowTokensUnder100kClicked = {
onShowTokensUnder100kClicked()
state.update { s ->
(s.list as? ListUM.Content)?.let {
s.copy(
list = s.list.copy(
items = searchUiItemsCached,
shouldShowUnder100kTokensNotification = false,
shouldShowUnder100kTokensNotificationWasHidden = true,
),
)
} ?: s
}
},
),
)
} else {
return currentState.copy(
list = generalContentState(newItems.filterPriceChangeByVisibility()),
)
}
}
private fun MarketsListUM.showUnder100kButtonAlreadyPressed(): Boolean {
return this.list is ListUM.Content &&
this.isInSearchMode &&
this.list.shouldShowUnder100kTokensNotificationWasHidden
}
// Show price change animation for visible items only
private fun ImmutableList<MarketsListItemUM>.filterPriceChangeByVisibility(): ImmutableList<MarketsListItemUM> {
val visibleItemIds = currentVisibleIds()
return map { marketsListItemUM ->
marketsListItemUM.copy(
price = marketsListItemUM.price.copy(
changeType = if (visibleItemIds.contains(marketsListItemUM.id)) {
marketsListItemUM.price.changeType
} else {
null
},
),
)
}.toImmutableList()
}
private fun generalContentState(newItems: ImmutableList<MarketsListItemUM>): ListUM.Content {
return ListUM.Content(
items = newItems,
loadMore = onLoadMoreUiItems,
visibleIdsChanged = visibleItemsChanged,
shouldShowUnder100kTokensNotification = false,
onShowTokensUnder100kClicked = {},
triggerScrollReset = consumedEvent(),
onItemClick = onTokenClick,
shouldShowUnder100kTokensNotificationWasHidden = false,
)
}
private fun state(): MarketsListUM = MarketsListUM(
list = ListUM.Loading,
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.markets_search_header_title),
query = "",
onQueryChange = { searchQuery = it },
isActive = false,
onActiveChange = { },
),
selectedSortBy = preselectedSortType(),
selectedInterval = MarketsListUM.TrendInterval.H24,
onIntervalClick = { selectedInterval = it },
onSortByButtonClick = { isSortByBottomSheetShown = true },
sortByBottomSheet = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = { isSortByBottomSheetShown = false },
content = SortByBottomSheetContentUM(
selectedOption = preselectedSortType(),
onOptionClicked = ::onBottomSheetOptionClicked,
),
),
marketsNotificationUM = null,
shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(),
)
private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) {
state.update { marketsListUM ->
marketsListUM.copy(
selectedSortBy = sortByTypeUM,
sortByBottomSheet = marketsListUM.sortByBottomSheet.copy(
isShown = false,
content = (marketsListUM.sortByBottomSheet.content as SortByBottomSheetContentUM).copy(
selectedOption = sortByTypeUM,
),
),
)
}
}
private fun consumeTriggerResetScrollEvent() {
state.update { marketsListUM ->
marketsListUM.copy(
list = if (marketsListUM.list is ListUM.Content) {
marketsListUM.list.copy(
triggerScrollReset = consumedEvent(),
)
} else {
marketsListUM.list
},
)
}
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.feed.model.market.list.utils
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.TokenMarketUpdateRequest
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchUpdateResult
import com.tangem.pagination.PaginationStatus
import timber.log.Timber
internal fun logStatus(tag: String, status: PaginationStatus<List<TokenMarket>>) {
Timber.tag(tag).d(
"""
Status
$status
""".trimIndent(),
)
}
internal fun logAction(tag: String, action: BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>) {
when (action) {
is BatchAction.Reload -> Timber.tag(tag).d(
"""
Reload = ${action.requestParams}
""".trimIndent(),
)
is BatchAction.UpdateBatches -> Timber.tag(tag).d(
"""
To update:
keys: ${action.keys.toList()}
updateType: ${action.updateRequest.javaClass.simpleName}
""".trimIndent(),
)
else -> Timber.tag(tag).d(
"""
$action
""".trimIndent(),
)
}
}
internal fun logUpdateResults(
tag: String,
updateResult: Pair<TokenMarketUpdateRequest, BatchUpdateResult<Int, List<TokenMarket>>>,
) {
val sec = when (val s = updateResult.second) {
is BatchUpdateResult.Success -> "Success"
is BatchUpdateResult.Error -> s.throwable.toString()
}
Timber.tag(tag).d(
"""
updateResults
request: ${updateResult.first}
result: $sec
""".trimIndent(),
)
}

View file

@ -1,6 +1,9 @@
package com.tangem.features.feed.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
@ -12,6 +15,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.features.feed.components.FeedEntryChildFactory
@Composable
@ -20,8 +24,10 @@ internal fun EntryBottomSheetContent(
onHeaderSizeChange: (Dp) -> Unit,
) {
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
Scaffold(
containerColor = background,
contentWindowInsets = WindowInsets(0.dp),
topBar = {
AnimatedContent(
@ -33,13 +39,15 @@ internal fun EntryBottomSheetContent(
}
}
},
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentState ->
currentState.Title()
}
},
content = { contentPadding ->
AnimatedContent(
stackState.active.instance,
targetState = stackState.active.instance,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentState ->
currentState.Content(modifier = Modifier.padding(contentPadding))
}

View file

@ -52,7 +52,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
@Composable
internal fun FeedListHeader(searchBarUM: SearchBarUM, modifier: Modifier = Modifier) {
@ -269,6 +269,7 @@ private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendi
}
}
@Suppress("LongMethod")
@Composable
private fun NewsContentBlock(
feedListCallbacks: FeedListCallbacks,
@ -315,21 +316,23 @@ private fun NewsContentBlock(
)
SpacerH(12.dp)
trendingArticle?.let { article ->
ArticleCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
SpacerH(12.dp)
if (trendingArticle != null) {
Column {
ArticleCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
articleConfigUM = trendingArticle,
onArticleClick = { feedListCallbacks.onArticleClick(trendingArticle.id) },
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
SpacerH(12.dp)
}
}
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
) {
@ -347,6 +350,7 @@ private fun NewsContentBlock(
)
}
}
SpacerH(32.dp)
}
}
@ -388,10 +392,16 @@ private fun Charts(
}
}
is MarketChartUM.LoadingError -> {
UnableToLoadData(
onRetryClick = marketChart.onRetryClicked,
modifier = Modifier.fillMaxWidth(),
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 35.dp, horizontal = 10.dp),
) {
UnableToLoadData(
onRetryClick = marketChart.onRetryClicked,
modifier = Modifier.fillMaxWidth(),
)
}
}
is MarketChartUM.Content -> {
marketChart.items.fastForEach { chart ->
@ -468,10 +478,18 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) {
onSeeAllClick = {},
)
SpacerH(12.dp)
UnableToLoadData(
onRetryClick = onRetryClick,
modifier = Modifier.fillMaxWidth(),
)
BlockCard(
modifier = Modifier.padding(horizontal = 16.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
UnableToLoadData(
onRetryClick = onRetryClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 35.dp, horizontal = 10.dp),
)
}
}
}
@ -481,7 +499,7 @@ private const val GRADIENT_END = 0.5f
private val LinearGradientFirstPart = Color(0xFF635EEC)
private val LinearGradientSecondPart = Color(0xFFE05AED)
@Preview(showBackground = true)
@Preview(showBackground = true, heightDp = 1500)
@Composable
private fun FeedListPreview() {
TangemThemePreview {

View file

@ -11,7 +11,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import kotlinx.collections.immutable.*
@Suppress("MagicNumber")

View file

@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toPersistentList

View file

@ -5,8 +5,8 @@ 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
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult

View file

@ -0,0 +1,350 @@
package com.tangem.features.feed.ui.market.list
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.list.components.MarketsListLazyColumn
import com.tangem.features.feed.ui.market.list.components.MarketsListSortByBottomSheet
import com.tangem.features.feed.ui.market.list.components.YieldSupplyInMarketsPromoNotification
import com.tangem.features.feed.ui.market.list.state.*
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
private const val SHOW_MORE_KEY = "privacyPolicy"
@Composable
internal fun TopBarWithSearch(searchBarUM: SearchBarUM) {
val background = LocalMainBottomSheetColor.current.value
SearchBar(
modifier = Modifier
.drawBehind { drawRect(background) }
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
state = searchBarUM,
colors = TangemSearchBarDefaults.defaultTextFieldColors.copy(
focusedContainerColor = TangemTheme.colors.field.focused,
unfocusedContainerColor = TangemTheme.colors.field.focused,
),
)
}
@Composable
internal fun MarketsList(state: MarketsListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.drawBehind { drawRect(background) },
) {
Content(state = state)
}
MarketsListSortByBottomSheet(config = state.sortByBottomSheet)
KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown)
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modifier) {
val strokeColor = TangemTheme.colors.stroke.primary
val scrolledState = remember { mutableStateOf(false) }
Column(modifier.padding(horizontal = TangemTheme.dimens.size16)) {
AnimatedVisibility(
visible = scrolledState.value.not(),
) {
Column {
SpacerH8()
Title(isInSearchMode = state.isInSearchMode)
SpacerH12()
}
}
Column {
AnimatedVisibility(state.isInSearchMode.not()) {
Options(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
sortByTypeUM = state.selectedSortBy,
trendInterval = state.selectedInterval,
onIntervalClick = state.onIntervalClick,
onSortByClick = state.onSortByButtonClick,
)
}
val marketsNotification = state.marketsNotificationUM
AnimatedVisibility(
state.isInSearchMode.not() &&
state.selectedSortBy != SortByTypeUM.YieldSupply,
) {
val showMore = stringResourceSafe(R.string.common_show_more)
when (marketsNotification) {
is MarketsNotificationUM.YieldSupplyPromo -> {
val description = stringResourceSafe(
R.string.markets_yield_supply_banner_description,
showMore,
)
val clickableDescription = annotatedReference {
append(description.substringBefore(showMore))
pushStringAnnotation(SHOW_MORE_KEY, "")
appendColored(showMore, TangemTheme.colors.text.accent)
pop()
}
YieldSupplyInMarketsPromoNotification(
config = marketsNotification.config.copy(
subtitle = clickableDescription,
),
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
)
}
else -> { /* no-op */
}
}
}
}
}
val strokeWidth = TangemTheme.dimens.size0_5
Box(
Modifier
.fillMaxWidth()
.height(strokeWidth)
.drawBehind {
// draw horizontal line
if (scrolledState.value) {
drawLine(
color = strokeColor,
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokeWidth.toPx(),
)
}
},
)
ItemsList(
scrolledState = scrolledState,
isInSearchMode = state.isInSearchMode,
state = state.list,
)
}
@Composable
private fun Title(isInSearchMode: Boolean, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = if (isInSearchMode) {
stringResourceSafe(id = R.string.markets_search_result_title)
} else {
stringResourceSafe(id = R.string.markets_common_title)
},
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
}
@Composable
private fun Options(
sortByTypeUM: SortByTypeUM,
trendInterval: MarketsListUM.TrendInterval,
onSortByClick: () -> Unit,
onIntervalClick: (MarketsListUM.TrendInterval) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.height(IntrinsicSize.Max)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
SecondarySmallButton(
config = SmallButtonConfig(
text = sortByTypeUM.text,
onClick = onSortByClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
),
)
SegmentedButtons(
config = persistentListOf(
MarketsListUM.TrendInterval.H24,
MarketsListUM.TrendInterval.D7,
MarketsListUM.TrendInterval.M1,
),
color = TangemTheme.colors.button.secondary,
initialSelectedItem = trendInterval,
onClick = onIntervalClick,
modifier = Modifier
.width(160.dp)
.fillMaxHeight(),
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.text.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
}
@Composable
private fun ItemsList(
scrolledState: MutableState<Boolean>,
isInSearchMode: Boolean,
state: ListUM,
modifier: Modifier = Modifier,
) {
val searchLazyListState = rememberLazyListState()
val mainLazyListState = rememberLazyListState()
val isMainScrolled by remember {
derivedStateOf {
mainLazyListState.firstVisibleItemScrollOffset > 0
}
}
val isSearchScrolled by remember {
derivedStateOf {
searchLazyListState.firstVisibleItemScrollOffset > 0
}
}
LaunchedEffect(isMainScrolled, isInSearchMode, isSearchScrolled) {
scrolledState.value = if (isInSearchMode) {
isSearchScrolled
} else {
isMainScrolled
}
}
MarketsListLazyColumn(
modifier = modifier,
state = state,
isInSearchMode = isInSearchMode,
lazyListState = if (isInSearchMode) {
searchLazyListState
} else {
mainLazyListState
},
)
}
@Composable
private fun KeyboardEvents(isSortByBottomSheetShown: Boolean) {
val keyboardController = LocalSoftwareKeyboardController.current
val keyboard by keyboardAsState()
val focusManager = LocalFocusManager.current
BackHandler(enabled = keyboard is Keyboard.Opened) {
keyboardController?.hide()
}
LaunchedEffect(keyboard) {
if (keyboard is Keyboard.Closed) {
focusManager.clearFocus()
}
}
LaunchedEffect(isSortByBottomSheetShown) {
keyboardController?.hide()
}
}
//region: Preview
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview(alwaysShowBottomSheets = false) {
val primaryBackground = TangemTheme.colors.background.primary
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember { mutableStateOf(primaryBackground) },
) {
MarketsList(
state = MarketsListUM(
list = ListUM.Content(
items = MarketChartListItemPreviewDataProvider().values
.flatMap { item -> List(size = 10) { item } }
.mapIndexed { index, item ->
item.copy(id = CryptoCurrency.RawID(index.toString()))
}
.toImmutableList(),
shouldShowUnder100kTokensNotification = false,
shouldShowUnder100kTokensNotificationWasHidden = false,
loadMore = {},
visibleIdsChanged = {},
onShowTokensUnder100kClicked = {},
triggerScrollReset = consumedEvent(),
onItemClick = {},
),
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.markets_search_header_title),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
),
selectedSortBy = SortByTypeUM.Rating,
selectedInterval = MarketsListUM.TrendInterval.H24,
onIntervalClick = {},
onSortByButtonClick = {},
sortByBottomSheet = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {},
),
marketsNotificationUM = MarketsNotificationUM.YieldSupplyPromo(
onClick = {},
onCloseClick = {},
),
shouldAlwaysShowSearchBar = true,
),
)
}
}
}
//endregion: Preview

View file

@ -0,0 +1,214 @@
package com.tangem.features.feed.ui.market.list.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM.Companion.TOKEN_LAZY_LIST_ID_SEPARATOR
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.list.state.ListUM
import kotlinx.coroutines.launch
private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50
private const val LOAD_NEXT_PAGE_ON_END_INDEX_SEARCH = 25
@Composable
@Suppress("LongMethod")
internal fun MarketsListLazyColumn(
state: ListUM,
isInSearchMode: Boolean,
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
val coroutineScope = rememberCoroutineScope()
SideEffect {
if (state is ListUM.Loading) {
coroutineScope.launch {
lazyListState.scrollToItem(0)
}
}
}
if (state is ListUM.Content) {
EventEffect(state.triggerScrollReset) {
lazyListState.scrollToItem(0)
}
}
if (state is ListUM.Loading) {
LazyColumn(
modifier = modifier,
state = rememberLazyListState(),
contentPadding = PaddingValues(bottom = bottomBarHeight),
userScrollEnabled = false,
) {
items(count = 100, key = { it }) {
MarketsListItemPlaceholder()
}
}
} else {
LazyColumn(
modifier = modifier.testTag(MarketsTestTags.TOKENS_LIST),
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight),
userScrollEnabled = true,
) {
// ATTENTION! There should be no elements with a string key value except MarketsListItem!
when (state) {
is ListUM.LoadingError -> {
item(key = "loading error".hashCode()) {
LoadingErrorItem(
modifier = Modifier.fillParentMaxSize(),
onTryAgain = state.onRetryClicked,
)
}
}
ListUM.SearchNothingFound -> {
item(key = "not found text".hashCode()) {
SearchNothingFoundText(
modifier = Modifier.fillParentMaxSize(),
)
}
}
is ListUM.Content -> {
items(
items = state.items,
key = { it.getComposeKey() },
) { item ->
MarketsListItem(
model = item,
onClick = { state.onItemClick(item) },
)
}
if (isInSearchMode && state.shouldShowUnder100kTokensNotification) {
item(key = "show tokens under 100k".hashCode()) {
ShowTokensUnder100kItem(
onShowTokensClick = state.onShowTokensUnder100kClicked,
)
}
}
}
else -> {}
}
}
}
VisibleItemsTracker(lazyListState, state)
InfiniteListHandler(
listState = lazyListState,
buffer = if (isInSearchMode) {
LOAD_NEXT_PAGE_ON_END_INDEX_SEARCH
} else {
LOAD_NEXT_PAGE_ON_END_INDEX
},
triggerLoadMoreCheckOnItemsCountChange = true,
onLoadMore = remember(state) {
{
if (state is ListUM.Content && state.shouldShowUnder100kTokensNotification.not()) {
state.loadMore()
true
} else {
false
}
}
},
)
}
@Composable
private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = onTryAgain)
}
}
@Composable
private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.markets_search_see_tokens_under_100k),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.markets_search_show_tokens),
onClick = onShowTokensClick,
),
)
}
}
@Composable
private fun SearchNothingFoundText(modifier: Modifier = Modifier) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Text(
text = stringResourceSafe(R.string.markets_search_token_no_result_title),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) {
val visibleItems by remember {
derivedStateOf {
listState.layoutInfo.visibleItemsInfo.mapNotNull { lazyListItemInfo ->
(lazyListItemInfo.key as? String)
?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)
?.first()
?.let { rawId -> CryptoCurrency.RawID(rawId) }
}
}
}
LaunchedEffect(listState.isScrollInProgress, visibleItems) {
if (state is ListUM.Content && listState.isScrollInProgress.not()) {
state.visibleIdsChanged(visibleItems)
}
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.features.feed.ui.market.list.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.inputrow.InputRowChecked
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.list.state.SortByBottomSheetContentUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
@Composable
fun MarketsListSortByBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<SortByBottomSheetContentUM>(
config = config,
titleText = resourceReference(R.string.markets_sort_by_title),
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: SortByBottomSheetContentUM) {
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
SortByTypeUM.entries.forEachIndexed { index, type ->
DividerContainer(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = SortByTypeUM.entries.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action)
.clickable { content.onOptionClicked(type) },
showDivider = index != SortByTypeUM.entries.lastIndex,
) {
InputRowChecked(
text = type.text,
checked = type == content.selectedOption,
)
}
}
}
}
@Preview(widthDp = 360, heightDp = 640)
@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview(
alwaysShowBottomSheets = true,
) {
Box(Modifier.background(TangemTheme.colors.background.secondary)) {
MarketsListSortByBottomSheet(
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = SortByBottomSheetContentUM(
selectedOption = SortByTypeUM.Trending,
onOptionClicked = {},
),
),
)
}
}
}

View file

@ -0,0 +1,157 @@
package com.tangem.features.feed.ui.market.list.components
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
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.notifications.CloseableIconButton
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
private val bgColor = Color(0x1F8CD9FF)
private val borderColor = Color(0x3D8CD9FF)
@Composable
fun StakingInMarketsPromoNotification(config: NotificationConfig, modifier: Modifier = Modifier) {
var textHeightDp by remember { mutableStateOf(0.dp) }
Box(
modifier = modifier
.fillMaxWidth()
.border(
width = 1.dp,
shape = TangemTheme.shapes.roundedCornersXMedium,
color = borderColor,
)
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.background(bgColor)
.clickable { config.onClick?.invoke() },
) {
PromoImage(
iconRes = config.iconResId,
modifier = Modifier.height(textHeightDp),
)
PromoText(
title = config.title,
subtitle = config.subtitle,
onSizeChange = { textHeightDp = it },
)
CloseableIconButton(
onClick = config.onCloseClick,
modifier = Modifier.align(alignment = Alignment.TopEnd),
iconTint = TangemTheme.colors.icon.secondary,
)
}
}
@Composable
private fun PromoImage(@DrawableRes iconRes: Int, modifier: Modifier = Modifier) {
Box(
modifier = modifier.padding(12.dp),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(id = iconRes),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.requiredWidth(56.dp)
.wrapContentHeight(Alignment.Top, unbounded = true),
)
}
}
@Composable
private fun PromoText(title: TextReference?, subtitle: TextReference, onSizeChange: (Dp) -> Unit) {
val density = LocalDensity.current
Box(
modifier = Modifier.onSizeChanged {
with(density) { onSizeChange(it.height.toDp()) }
},
) {
TextsBlock(
title = title,
subtitle = subtitle,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.CenterStart)
.padding(start = 76.dp, top = 12.dp, end = 12.dp, bottom = 12.dp),
)
}
}
@Composable
private fun TextsBlock(title: TextReference?, subtitle: TextReference, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
val titleText = title?.resolveReference()
if (titleText != null) {
Text(
text = titleText,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.button,
)
SpacerH(height = TangemTheme.dimens.spacing2)
}
Text(
text = subtitle.resolveAnnotatedReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.caption2,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun RingPromoNotification_Preview() {
TangemThemePreview {
StakingInMarketsPromoNotification(
config = NotificationConfig(
title = stringReference("Earn up to 14% APY"),
subtitle = stringReference("Staking is the easiest way to earn rewards on your crypto. Show more"),
iconResId = R.drawable.img_staking_in_market_notification,
onCloseClick = { },
),
)
}
}
// endregion

View file

@ -0,0 +1,157 @@
package com.tangem.features.feed.ui.market.list.components
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
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.notifications.CloseableIconButton
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
private val bgColor = Color(0x2684B0D7)
private val borderColor = Color(0x2684B0D7)
@Composable
fun YieldSupplyInMarketsPromoNotification(config: NotificationConfig, modifier: Modifier = Modifier) {
var textHeightDp by remember { mutableStateOf(0.dp) }
Box(
modifier = modifier
.fillMaxWidth()
.border(
width = 1.dp,
shape = TangemTheme.shapes.roundedCornersXMedium,
color = borderColor,
)
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.background(bgColor)
.clickable { config.onClick?.invoke() },
) {
PromoImage(
iconRes = config.iconResId,
modifier = Modifier.height(textHeightDp),
)
PromoText(
title = config.title,
subtitle = config.subtitle,
onSizeChange = { textHeightDp = it },
)
CloseableIconButton(
onClick = config.onCloseClick,
modifier = Modifier.align(alignment = Alignment.TopEnd),
iconTint = TangemTheme.colors.icon.secondary,
)
}
}
@Composable
private fun PromoImage(@DrawableRes iconRes: Int, modifier: Modifier = Modifier) {
Box(
modifier = modifier.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(id = iconRes),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.requiredWidth(80.dp)
.wrapContentHeight(Alignment.CenterVertically, unbounded = true),
)
}
}
@Composable
private fun PromoText(title: TextReference?, subtitle: TextReference, onSizeChange: (Dp) -> Unit) {
val density = LocalDensity.current
Box(
modifier = Modifier.onSizeChanged {
with(density) { onSizeChange(it.height.toDp()) }
},
) {
TextsBlock(
title = title,
subtitle = subtitle,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.CenterStart)
.padding(start = 80.dp, top = 12.dp, end = 12.dp, bottom = 12.dp),
)
}
}
@Composable
private fun TextsBlock(title: TextReference?, subtitle: TextReference, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
val titleText = title?.resolveReference()
if (titleText != null) {
Text(
text = titleText,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.button,
)
SpacerH(height = TangemTheme.dimens.spacing2)
}
Text(
text = subtitle.resolveAnnotatedReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.caption2,
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun RingPromoNotification_Preview() {
TangemThemePreview {
YieldSupplyInMarketsPromoNotification(
config = NotificationConfig(
title = stringReference("Activate Yield Mode"),
subtitle = stringReference("Power up your assets while supplying them with instant access. Show more"),
iconResId = R.drawable.img_yield_supply_in_market_notification,
onCloseClick = { },
),
)
}
}
// endregion

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.market.state
package com.tangem.features.feed.ui.market.list.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
@ -10,7 +10,6 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
internal data class MarketsListUM(
val list: ListUM,
@ -18,11 +17,10 @@ internal data class MarketsListUM(
val selectedSortBy: SortByTypeUM,
val sortByBottomSheet: TangemBottomSheetConfig,
val selectedInterval: TrendInterval,
val shouldAlwaysShowSearchBar: Boolean,
val onIntervalClick: (TrendInterval) -> Unit,
val onSortByButtonClick: () -> Unit,
val stakingNotificationMaxApy: BigDecimal?,
val onStakingNotificationClick: () -> Unit,
val onStakingNotificationCloseClick: () -> Unit,
val marketsNotificationUM: MarketsNotificationUM?,
) {
val isInSearchMode
get() = searchBar.isActive

View file

@ -0,0 +1,22 @@
package com.tangem.features.feed.ui.market.list.state
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.feed.impl.R
internal sealed class MarketsNotificationUM(val config: NotificationConfig) {
data class YieldSupplyPromo(
val onClick: () -> Unit,
val onCloseClick: () -> Unit,
) : MarketsNotificationUM(
config = NotificationConfig(
iconResId = R.drawable.img_yield_supply_in_market_notification,
title = resourceReference(R.string.markets_yield_supply_banner_title),
subtitle = TextReference.EMPTY,
onClick = onClick,
onCloseClick = onCloseClick,
),
)
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.feed.ui.market.list.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
data class SortByBottomSheetContentUM(
val selectedOption: SortByTypeUM,
val onOptionClicked: (SortByTypeUM) -> Unit,
) : TangemBottomSheetConfigContent