Updated on 2026-08-14
This commit is contained in:
parent
b449775ad8
commit
078e8ed336
25 changed files with 2184 additions and 48 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue