Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-20 09:39:36 +01:00
parent 8ca7bc0ca7
commit ebe571aa16
30 changed files with 17 additions and 3177 deletions

View file

@ -24,7 +24,6 @@ import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.tokenlist.MarketsTokenListComponent
import com.tangem.features.nft.component.NFTComponent import com.tangem.features.nft.component.NFTComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
import com.tangem.features.onramp.component.* import com.tangem.features.onramp.component.*
@ -64,7 +63,6 @@ internal class ChildFactory @Inject constructor(
private val walletHardwareBackupComponentFactory: WalletHardwareBackupComponent.Factory, private val walletHardwareBackupComponentFactory: WalletHardwareBackupComponent.Factory,
private val disclaimerComponentFactory: DisclaimerComponent.Factory, private val disclaimerComponentFactory: DisclaimerComponent.Factory,
private val manageTokensComponentFactory: ManageTokensComponent.Factory, private val manageTokensComponentFactory: ManageTokensComponent.Factory,
private val marketsTokenListComponentFactory: MarketsTokenListComponent.FactoryScreen,
private val onrampComponentFactory: OnrampComponent.Factory, private val onrampComponentFactory: OnrampComponent.Factory,
private val onrampSuccessComponentFactory: OnrampSuccessComponent.Factory, private val onrampSuccessComponentFactory: OnrampSuccessComponent.Factory,
private val buyCryptoComponentFactory: BuyCryptoComponent.Factory, private val buyCryptoComponentFactory: BuyCryptoComponent.Factory,
@ -459,8 +457,8 @@ internal class ChildFactory @Inject constructor(
is AppRoute.Markets -> { is AppRoute.Markets -> {
createComponentChild( createComponentChild(
context = context, context = context,
params = Unit, params = FeedEntryRoute.MarketTokenList,
componentFactory = marketsTokenListComponentFactory, componentFactory = feedEntryComponentFactory,
) )
} }
is AppRoute.Usedesk -> { // TODO [REDACTED_TASK_KEY] pass params is AppRoute.Usedesk -> { // TODO [REDACTED_TASK_KEY] pass params

View file

@ -6,9 +6,9 @@ import com.tangem.common.routing.DeepLinkRoute
import com.tangem.common.routing.DeepLinkScheme import com.tangem.common.routing.DeepLinkScheme
import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler

View file

@ -4,9 +4,9 @@ import android.net.Uri
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.deeplink package com.tangem.features.feed.entry.deeplink
interface MarketsDeepLinkHandler { interface MarketsDeepLinkHandler {

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.deeplink package com.tangem.features.feed.entry.deeplink
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope

View file

@ -1,7 +1,8 @@
package com.tangem.features.markets.deeplink package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter import com.tangem.common.routing.AppRouter
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject

View file

@ -1,4 +1,4 @@
package com.tangem.features.markets.deeplink package com.tangem.features.feed.deeplink
import arrow.core.getOrElse import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
@ -10,6 +10,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject

View file

@ -1,9 +1,9 @@
package com.tangem.features.markets.deeplink.di package com.tangem.features.feed.deeplink.di
import com.tangem.features.markets.deeplink.DefaultMarketsDeepLinkHandler import com.tangem.features.feed.deeplink.DefaultMarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.DefaultMarketsTokenDetailDeepLinkHandler import com.tangem.features.feed.deeplink.DefaultMarketsTokenDetailDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.markets.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn

View file

@ -1,34 +0,0 @@
package com.tangem.features.markets.tokenlist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
@Stable
interface MarketsTokenListComponent : ComposableContentComponent {
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface FactoryScreen : ComponentFactory<Unit, MarketsTokenListComponent>
interface FactoryBottomSheet {
fun create(
context: AppComponentContext,
params: Unit,
onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)?,
): MarketsTokenListComponent
}
}

View file

@ -1,131 +0,0 @@
package com.tangem.features.markets.tokenlist.impl
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRoute.MarketsTokenDetails.AnalyticsParams
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.markets.toSerializableParam
import com.tangem.features.markets.tokenlist.MarketsTokenListComponent
import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel
import com.tangem.features.markets.tokenlist.impl.ui.MarketsList
import com.tangem.features.markets.tokenlist.impl.ui.MarketsListWithBack
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Suppress("UnusedPrivateMember")
internal class DefaultMarketsTokenListComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Unit,
@Assisted onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)? = null,
) : AppComponentContext by appComponentContext, MarketsTokenListComponent {
private val model: MarketsListModel = getOrCreateModel()
init {
model.tokenSelected
.onEach { (token, appCurrency) ->
// If specific routing call back is provided, invoke it (for example inner routing in Bottom Sheet)
// Otherwise navigate using app routing
if (onTokenClick != null) {
onTokenClick(token.toSerializableParam(), appCurrency)
} else {
router.push(
AppRoute.MarketsTokenDetails(
token = token.toSerializableParam(),
appCurrency = appCurrency,
shouldShowPortfolio = true,
analyticsParams = AnalyticsParams(
blockchain = null,
source = "Market",
),
),
)
}
}
.launchIn(componentScope)
}
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.containerBottomSheetState.value = bsState
}
MarketsList(
modifier = modifier,
state = state,
onHeaderSizeChange = onHeaderSizeChange,
bottomSheetState = bsState,
)
}
@Composable
override fun Content(modifier: Modifier) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.primary,
) { paddingValues ->
MarketsListWithBack(
modifier = Modifier
.statusBarsPadding()
.imePadding()
.padding(paddingValues),
state = state,
bottomSheetState = BottomSheetState.EXPANDED,
onBackClick = router::pop,
)
}
}
@AssistedFactory
interface FactoryBottomSheet : MarketsTokenListComponent.FactoryBottomSheet {
override fun create(
context: AppComponentContext,
params: Unit,
onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)?,
): DefaultMarketsTokenListComponent
}
}

View file

@ -1,50 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM
internal sealed class MarketsListAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets", event = event, params = params) {
class BottomSheetOpened : MarketsListAnalyticsEvent(event = "Markets Screen Opened")
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 wasTokenFound: Boolean) : MarketsListAnalyticsEvent(
event = "Token Searched",
params = mapOf(
"Result" to if (wasTokenFound) "Yes" else "No",
),
)
class ShowTokens : MarketsListAnalyticsEvent(event = "Button - Show Tokens")
}

View file

@ -1,39 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.di
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.features.markets.tokenlist.MarketsTokenListComponent
import com.tangem.features.markets.tokenlist.impl.DefaultMarketsTokenListComponent
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindMarketsTokenListBottomSheetComponent(
factory: DefaultMarketsTokenListComponent.FactoryBottomSheet,
): MarketsTokenListComponent.FactoryBottomSheet
}
@Module
@InstallIn(SingletonComponent::class)
internal class ComponentProvideModule {
@Provides
@Singleton
fun providesMarketsTokenListScreenComponent(
factory: DefaultMarketsTokenListComponent.FactoryBottomSheet,
): MarketsTokenListComponent.FactoryScreen {
return object : MarketsTokenListComponent.FactoryScreen {
override fun create(context: AppComponentContext, params: Unit): MarketsTokenListComponent {
return factory.create(context, params, null)
}
}
}
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(MarketsListModel::class)
fun provideMarketsListModel(model: MarketsListModel): Model
}

View file

@ -1,344 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.model
import androidx.compose.runtime.Stable
import arrow.core.Either
import arrow.core.getOrElse
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.ui.components.bottomsheets.state.BottomSheetState
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.TokenMarket
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.UserCountryError
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.features.markets.tokenlist.impl.analytics.MarketsListAnalyticsEvent
import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListBatchFlowManager
import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListUMStateManager
import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval
import com.tangem.features.markets.tokenlist.impl.ui.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")
internal class MarketsListModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
shouldShowYieldModeMarketPromoUseCase: ShouldShowYieldModeMarketPromoUseCase,
private val promoRepository: PromoRepository,
private val getUserCountryUseCase: GetUserCountryUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model() {
private val updateQuotesJob = JobHolder()
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 lateinit var activeListManager: MarketsListBatchFlowManager
private val marketsListUMStateManager = MarketsListUMStateManager(
currentVisibleIds = Provider { visibleItemIds.value },
onLoadMoreUiItems = { activeListManager.loadMore() },
visibleItemsChanged = { visibleItemIds.value = it },
onRetryButtonClicked = { activeListManager.reload() },
onTokenClick = { onTokenUIClicked(it) },
onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) },
)
private val mainMarketsListManager = 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 = 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,
)
init {
activeListManager = mainMarketsListManager
}
private val _tokenSelected = MutableSharedFlow<Pair<TokenMarket, AppCurrency>>()
val tokenSelected = _tokenSelected.asSharedFlow()
val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED)
val isVisibleOnScreen = MutableStateFlow(false)
val state = marketsListUMStateManager.state.asStateFlow()
init {
@Suppress("UnnecessaryParentheses") 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(),
).conflate(),
flow5 = getUserCountryUseCase.invoke(),
) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, isYieldModePromo, userCountry ->
MarketsItemsData(
items = uiItems,
isInErrorState = isInInitialLoadingErrorState,
isSearchNotFound = isSearchNotFoundState,
shouldShowYieldModePromo = isYieldModePromo,
userCountry = userCountry,
)
}
} else {
combine(
flow = mainMarketsListManager.uiItems,
flow2 = mainMarketsListManager.isInInitialLoadingErrorState,
flow3 = shouldShowYieldModeMarketPromoUseCase(
appCurrency = currentAppCurrency.value,
interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(),
).conflate(),
flow4 = getUserCountryUseCase.invoke(),
) { uiItems, isInInitialLoadingErrorState, shouldShowYieldModePromo, userCountry ->
MarketsItemsData(
items = uiItems,
isInErrorState = isInInitialLoadingErrorState,
isSearchNotFound = false,
shouldShowYieldModePromo = shouldShowYieldModePromo,
userCountry = userCountry,
)
}
}
}.collect { marketsItemsData ->
val isApplyFCARestrictions = marketsItemsData.userCountry.getOrNull().needApplyFCARestrictions()
val shouldShowYieldModePromo = marketsItemsData.shouldShowYieldModePromo && !isApplyFCARestrictions
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 { currentState ->
if (currentState.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 { state -> state.selectedSortBy }
.distinctUntilChanged()
.drop(1)
.collectLatest {
mainMarketsListManager.reload()
}
}
// listen current visible batch and update charts
modelScope.launch {
visibleItemIds.mapNotNull { itemIds ->
if (itemIds.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 { isNotFound ->
if (isNotFound) {
analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(wasTokenFound = false))
}
}.launchIn(modelScope)
searchMarketsListManager.onFirstBatchLoadedSuccess.onEach {
analyticsEventHandler.send(MarketsListAnalyticsEvent.TokenSearched(wasTokenFound = true))
}.launchIn(modelScope)
// analytics
initAnalytics()
// initial loading
mainMarketsListManager.reload()
}
fun TrendInterval.toBatchRequestInterval(): TokenMarketListConfig.Interval {
return when (this) {
TrendInterval.H24 -> TokenMarketListConfig.Interval.H24
TrendInterval.D7 -> TokenMarketListConfig.Interval.WEEK
TrendInterval.M1 -> TokenMarketListConfig.Interval.MONTH
}
}
private fun initAnalytics() {
containerBottomSheetState.onEach { bottomSheetState ->
if (bottomSheetState == BottomSheetState.EXPANDED) {
analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened())
}
}.launchIn(modelScope)
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 ->
_tokenSelected.emit(found to currentAppCurrency.value)
}
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {
delay(timeMillis)
// Update quotes only when the container bottom sheet is in the expanded state
containerBottomSheetState.first { it == BottomSheetState.EXPANDED }
// and is visible on the screen
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,
val userCountry: Either<UserCountryError, UserCountry>,
)
}

View file

@ -1,22 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.model
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.markets.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

@ -1,159 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.model.converters
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
internal class MarketsTokenItemConverter(
private val currentTrendInterval: TrendInterval,
private val appCurrency: AppCurrency,
) : Converter<TokenMarket, MarketsListItemUM> {
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
override fun convert(value: TokenMarket): MarketsListItemUM {
return MarketsListItemUM(
id = value.id,
name = value.name,
currencySymbol = value.symbol,
ratingPosition = value.marketRating?.toString(),
marketCap = value.getMarketCap(),
iconUrl = value.imageUrlLarge,
price = value.getCurrentPrice(),
trendPercentText = value.getTrendPercent(),
trendType = value.getTrendType(),
chartData = value.getChartData(),
isUnder100kMarketCap = value.isUnderMarketCapLimit,
stakingRate = value.yieldRate?.format { percent() }?.let {
resourceReference(R.string.markets_apy_placeholder, wrappedList(it))
},
updateTimestamp = value.updateTimestamp,
)
}
fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM {
require(prev.id == new.id) {
"Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]"
}
return prevUI.copy(
name = new.name,
currencySymbol = new.symbol,
ratingPosition = new.marketRating?.toString(),
marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() },
iconUrl = new.imageUrlLarge,
price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) {
new.getCurrentPrice(
prev = prev,
)
},
trendPercentText = ifChanged(
prev.tokenQuotesShort,
new.tokenQuotesShort,
prevUI.trendPercentText,
) { new.getTrendPercent() },
trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() },
chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() },
)
}
private inline fun <T, R> ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R {
return if (force || prev != new) change(new) else prevR
}
private fun TokenMarket.getMarketCap(): String? {
val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null
return value.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).compact(
threeDigitsMethod = true,
)
}
}
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
val prevPrice = prev?.tokenQuotesShort?.currentPrice
val priceText = tokenQuotesShort.currentPrice.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).price()
}
val changeType = if (prevPrice != null) {
if (tokenQuotesShort.currentPrice > prevPrice) {
PriceChangeType.UP
} else {
PriceChangeType.DOWN
}
} else {
null
}
return MarketsListItemUM.Price(
text = priceText,
changeType = changeType,
)
}
private fun TokenMarket.getChartData(): MarketChartRawData? {
val chart = when (currentTrendInterval) {
TrendInterval.H24 -> tokenCharts.h24
TrendInterval.D7 -> tokenCharts.week
TrendInterval.M1 -> tokenCharts.month
}
return chart?.let { ct ->
priceAndTimePointValuesConverter.convert(
MarketChartData.Data(
y = ct.priceY.toImmutableList(),
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted(),
)
}
}
@Suppress("MagicNumber")
private fun TokenMarket.getTrendType(): PriceChangeType {
val percent = when (currentTrendInterval) {
TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent
TrendInterval.D7 -> tokenQuotesShort.weekChangePercent
TrendInterval.M1 -> tokenQuotesShort.monthChangePercent
}
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
private fun TokenMarket.getTrendPercent(): String {
val percent = when (currentTrendInterval) {
TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent
TrendInterval.D7 -> tokenQuotesShort.weekChangePercent
TrendInterval.M1 -> tokenQuotesShort.monthChangePercent
}
return percent.format { percent() }
}
}

View file

@ -1,367 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.model.statemanager
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.tokenlist.impl.model.converters.MarketsTokenItemConverter
import com.tangem.features.markets.tokenlist.impl.model.utils.logAction
import com.tangem.features.markets.tokenlist.impl.model.utils.logStatus
import com.tangem.features.markets.tokenlist.impl.model.utils.logUpdateResults
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval
import com.tangem.features.markets.tokenlist.impl.ui.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")
internal class MarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
private val currentTrendInterval: Provider<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 { state ->
when (val status = state.status) {
is PaginationStatus.Paginating -> {
if (status.lastResult is BatchFetchResult.Success) {
state.data.lastOrNull()?.key
} else {
null
}
}
is PaginationStatus.EndOfPagination -> {
state.data.lastOrNull()?.key
}
else -> null
}
}
val onFirstBatchLoadedSuccess = batchFlow.state
.distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size }
.mapNotNull { state ->
when (val status = state.status) {
is PaginationStatus.Paginating -> {
if (status.lastResult is BatchFetchResult.Success) {
state.data.size == 1
} else {
null
}
}
is PaginationStatus.EndOfPagination -> {
state.data.size == 1
}
else -> null
}
}
.filter { isFirstBatch -> isFirstBatch }
val isInInitialLoadingErrorState = batchFlow.state
.map { it.status is PaginationStatus.InitialLoadingError }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
val isSearchNotFoundState = batchFlow.state
.map { state ->
currentSearchText().isNullOrEmpty().not() &&
state.status is PaginationStatus.EndOfPagination &&
state.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: 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) {
TrendInterval.H24 -> first.tokenCharts.h24
TrendInterval.D7 -> first.tokenCharts.week
TrendInterval.M1 -> first.tokenCharts.month
}
chartByInterval != null
}
.map { batch -> batch.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
},
)
val batchKeys = batchFlow.state.value.data
.map { it.key }
.toSet()
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchKeys,
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? {
val allTokens = batchFlow.state.value.data
.map { it.data }
.flatten()
return allTokens.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 TrendInterval.toBatchRequestInterval(): TokenMarketListConfig.Interval {
return when (this) {
TrendInterval.H24 -> TokenMarketListConfig.Interval.H24
TrendInterval.D7 -> TokenMarketListConfig.Interval.WEEK
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

@ -1,260 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.model.statemanager
import androidx.compose.runtime.Stable
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.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.model.MarketsNotificationUM
import com.tangem.features.markets.tokenlist.impl.ui.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")
internal class MarketsListUMStateManager(
private val currentVisibleIds: Provider<List<CryptoCurrency.RawID>>,
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(createInitialState())
val isInSearchStateFlow = state.map { it.searchBar.isActive }.distinctUntilChanged()
val searchQueryFlow = state.map { it.searchBar.query }.distinctUntilChanged()
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 { currentState ->
currentState.copy(
searchBar = currentState.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 { currentState ->
currentState.copy(
selectedSortBy = value,
sortByBottomSheet = currentState.sortByBottomSheet.copy(
content = (currentState.sortByBottomSheet.content as SortByBottomSheetContentUM).copy(
selectedOption = value,
),
),
list = if (currentState.list is ListUM.Content && currentState.selectedSortBy != value) {
currentState.list.copy(
triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() },
)
} else {
currentState.list
},
)
}
var selectedInterval
get() = state.value.selectedInterval
set(value) = state.update { currentState ->
currentState.copy(
selectedInterval = value,
list = if (currentState.list is ListUM.Content &&
currentState.selectedSortBy != SortByTypeUM.Rating &&
currentState.selectedInterval != value
) {
currentState.list.copy(
triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() },
)
} else {
currentState.list
},
)
}
fun onUiItemsChanged(
isInErrorState: Boolean,
isSearchNotFound: Boolean,
uiItems: ImmutableList<MarketsListItemUM>,
marketsNotificationUM: MarketsNotificationUM?,
) {
state.update { currentState ->
when {
isInErrorState -> {
currentState.copy(
list = ListUM.LoadingError(onRetryClicked = onRetryButtonClicked),
)
}
isSearchNotFound -> {
currentState.copy(list = ListUM.SearchNothingFound)
}
uiItems.isEmpty() -> {
currentState.copy(list = ListUM.Loading)
}
else -> {
currentState.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(
wasUnder100kTokensNotificationHidden = 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(
wasUnder100kTokensNotificationHidden = false,
shouldShowUnder100kTokensNotification = true,
onShowTokensUnder100kClicked = {
onShowTokensUnder100kClicked()
state.update { s ->
(s.list as? ListUM.Content)?.let {
s.copy(
list = s.list.copy(
items = searchUiItemsCached,
shouldShowUnder100kTokensNotification = false,
wasUnder100kTokensNotificationHidden = 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.wasUnder100kTokensNotificationHidden
}
// Show price change animation for visible items only
private fun ImmutableList<MarketsListItemUM>.filterPriceChangeByVisibility(): ImmutableList<MarketsListItemUM> {
val visibleItemIds = currentVisibleIds()
return map { item ->
item.copy(
price = item.price.copy(
changeType = if (visibleItemIds.contains(item.id)) {
item.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,
wasUnder100kTokensNotificationHidden = false,
)
}
private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) {
state.update { currentState ->
currentState.copy(
selectedSortBy = sortByTypeUM,
sortByBottomSheet = currentState.sortByBottomSheet.copy(
isShown = false,
content = (currentState.sortByBottomSheet.content as SortByBottomSheetContentUM).copy(
selectedOption = sortByTypeUM,
),
),
)
}
}
private fun consumeTriggerResetScrollEvent() {
state.update { currentState ->
currentState.copy(
list = if (currentState.list is ListUM.Content) {
currentState.list.copy(
triggerScrollReset = consumedEvent(),
)
} else {
currentState.list
},
)
}
}
private fun createInitialState(): MarketsListUM = MarketsListUM(
list = ListUM.Loading,
searchBar = SearchBarUM(
placeholderText = resourceReference(R.string.markets_search_header_title),
query = "",
onQueryChange = { searchQuery = it },
isActive = false,
onActiveChange = { },
),
selectedSortBy = SortByTypeUM.Rating,
selectedInterval = MarketsListUM.TrendInterval.H24,
onIntervalClick = { selectedInterval = it },
onSortByButtonClick = { isSortByBottomSheetShown = true },
sortByBottomSheet = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = { isSortByBottomSheetShown = false },
content = SortByBottomSheetContentUM(
selectedOption = SortByTypeUM.Rating,
onOptionClicked = ::onBottomSheetOptionClicked,
),
),
marketsNotificationUM = null,
)
}

View file

@ -1,58 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.model.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,431 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
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.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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.bottomsheets.state.BottomSheetState
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.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.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.model.MarketsNotificationUM
import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListLazyColumn
import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListSortByBottomSheet
import com.tangem.features.markets.tokenlist.impl.ui.components.YieldSupplyInMarketsPromoNotification
import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider
import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
private const val SHOW_MORE_KEY = "privacyPolicy"
@Composable
internal fun MarketsList(
state: MarketsListUM,
onHeaderSizeChange: (Dp) -> Unit,
bottomSheetState: BottomSheetState,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.drawBehind { drawRect(background) },
) {
SearchBar(
modifier = Modifier
.drawBehind { drawRect(background) }
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = 8.dp,
)
.onGloballyPositioned { layoutCoordinates ->
if (layoutCoordinates.size.height > 0) {
with(density) {
onHeaderSizeChange(layoutCoordinates.size.height.toDp())
}
}
}
.padding(bottom = 4.dp),
state = state.searchBar,
)
Content(state = state)
}
MarketsListSortByBottomSheet(config = state.sortByBottomSheet)
KeyboardEvents(
isSortByBottomSheetShown = state.sortByBottomSheet.isShown,
bottomSheetState = bottomSheetState,
)
}
@Composable
internal fun MarketsListWithBack(
state: MarketsListUM,
bottomSheetState: BottomSheetState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.drawBehind { drawRect(background) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_close_24),
),
contentDescription = null,
tint = TangemTheme.colors.icon.primary1,
modifier = Modifier
.padding(16.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = onBackClick,
),
)
SearchBar(
modifier = Modifier
.drawBehind { drawRect(background) }
.padding(
end = 16.dp,
),
state = state.searchBar,
)
}
Content(state = state)
}
MarketsListSortByBottomSheet(config = state.sortByBottomSheet)
KeyboardEvents(
isSortByBottomSheetShown = state.sortByBottomSheet.isShown,
bottomSheetState = bottomSheetState,
)
}
@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 marketsNotificationUM = state.marketsNotificationUM
AnimatedVisibility(
state.list !is ListUM.LoadingError &&
state.isInSearchMode.not() && state.selectedSortBy != SortByTypeUM.YieldSupply,
) {
val showMore = stringResourceSafe(R.string.common_show_more)
when (marketsNotificationUM) {
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 = marketsNotificationUM.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, bottomSheetState: BottomSheetState) {
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()
}
LaunchedEffect(bottomSheetState) {
if (bottomSheetState == BottomSheetState.COLLAPSED) {
focusManager.clearFocus()
}
}
}
//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,
wasUnder100kTokensNotificationHidden = 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 = {},
),
),
onHeaderSizeChange = {},
bottomSheetState = BottomSheetState.EXPANDED,
)
}
}
}
//endregion: Preview

View file

@ -1,324 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.tangem.common.ui.charts.MarketChartMini
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.tokens.TokenPriceText
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContent(
modifier = modifier
.fillMaxWidth()
.clip(RectangleShape)
.clickable(onClick = onClick)
.testTag(MarketsTestTags.TOKENS_LIST_ITEM),
model = model,
)
}
@Composable
private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier = Modifier) {
val windowSize = LocalWindowSize.current
Row(
modifier = modifier.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing15,
),
verticalAlignment = Alignment.CenterVertically,
) {
CoinIcon(
modifier = Modifier.size(TangemTheme.dimens.size36),
url = model.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
SpacerW12()
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TokenTitle(
modifier = Modifier.weight(1f, fill = false),
name = model.name,
currencySymbol = model.currencySymbol,
)
SpacerW8()
TokenPriceText(
modifier = Modifier.alignByBaseline(),
price = model.price.text,
priceChangeType = model.price.changeType,
)
}
SpacerH(height = TangemTheme.dimens.spacing2)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
TokenSubtitle(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
ratingPosition = model.ratingPosition,
marketCap = model.marketCap,
stakingRate = model.stakingRate,
)
PriceChangeInPercent(
modifier = Modifier.alignByBaseline(),
textStyle = TangemTheme.typography.caption2,
type = model.trendType,
valueInPercent = model.trendPercentText,
)
}
}
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Spacer(Modifier.width(TangemTheme.dimens.spacing10))
Chart(
chartType = model.chartType,
chartRawData = model.chartData,
)
}
}
}
@Composable
private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier = Modifier) {
Row(modifier = modifier) {
Text(
modifier = Modifier
.weight(1f, fill = false)
.alignByBaseline(),
text = name,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerW4()
Text(
modifier = Modifier.alignByBaseline(),
text = currencySymbol,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun TokenSubtitle(
ratingPosition: String?,
marketCap: String?,
stakingRate: TextReference?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
TokenRatingPlace(ratingPosition = ratingPosition)
if (marketCap != null) {
SpacerW4()
TokenMarketCapText(
modifier = Modifier.weight(1f, fill = false),
text = marketCap,
)
}
if (stakingRate != null) {
SpacerW4()
StakingRate(stakingRate = stakingRate.resolveReference())
}
}
}
@Composable
private fun RowScope.TokenRatingPlace(ratingPosition: String?) {
Box(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens.size16)
.background(
color = TangemTheme.colors.field.primary,
shape = TangemTheme.shapes.roundedCornersSmall2,
)
.padding(horizontal = TangemTheme.dimens.spacing5),
) {
Text(
text = ratingPosition ?: MINUS,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun RowScope.StakingRate(stakingRate: String) {
Box(
modifier = Modifier
.alignByBaseline()
.heightIn(min = TangemTheme.dimens.size16)
.border(
width = TangemTheme.dimens.size1,
color = TangemTheme.colors.field.primary,
shape = TangemTheme.shapes.roundedCornersSmall2,
)
.padding(horizontal = TangemTheme.dimens.spacing5),
) {
Text(
text = stakingRate,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun RowScope.TokenMarketCapText(text: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier.alignByBaseline(),
text = text,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun Chart(chartType: MarketChartLook.Type, chartRawData: MarketChartRawData?) {
val chartWidth = TangemTheme.dimens.size56
Box(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing2)
.size(height = TangemTheme.dimens.size24, width = chartWidth),
) {
if (chartRawData != null) {
MarketChartMini(
rawData = chartRawData,
type = chartType,
)
} else {
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size12)
.align(Alignment.Center),
radius = TangemTheme.dimens.radius3,
)
}
}
}
// region preview
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 260, name = "small width")
@Composable
private fun Preview(@PreviewParameter(MarketChartListItemPreviewDataProvider::class) state: MarketsListItemUM) {
TangemThemePreview {
var state1 by remember { mutableStateOf(state) }
var state2 by remember { mutableStateOf(state) }
var prices by remember {
mutableStateOf(
listOf(
100 to PriceChangeType.NEUTRAL,
200 to PriceChangeType.NEUTRAL,
),
)
}
Column(modifier = Modifier.background(TangemTheme.colors.background.primary)) {
MarketsListItem(
modifier = Modifier,
model = state1,
)
MarketsListItem(
modifier = Modifier,
model = state2,
)
Row {
Button(
onClick = {
state1 = state1.copy(
trendType = PriceChangeType.entries.random(),
)
state2 = state2.copy(
trendType = PriceChangeType.entries.random(),
)
},
) { Text(text = "trend") }
Button(
onClick = {
prices = prices.map { priceInfo ->
if (Random.nextBoolean()) {
priceInfo.first.inc() to PriceChangeType.UP
} else {
priceInfo.first.dec() to PriceChangeType.DOWN
}
}
state1 = state1.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[0].first}023 $",
changeType = prices[0].second,
),
)
state2 = state2.copy(
price = MarketsListItemUM.Price(
text = "0.${prices[1].first}023 $",
changeType = prices[1].second,
),
)
},
) { Text(text = "price") }
}
}
}
}
// endregion preview

View file

@ -1,96 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.*
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.windowsize.WindowSizeType
@Suppress("LongMethod")
@Composable
fun MarketsListItemPlaceholder() {
val density = LocalDensity.current
val windowSize = LocalWindowSize.current
val sp12 = with(density) { 12.sp.toDp() }
Row(
modifier = Modifier.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing15,
),
verticalAlignment = Alignment.CenterVertically,
) {
CircleShimmer(Modifier.size(TangemTheme.dimens.size36))
SpacerW12()
Column(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing4),
contentAlignment = Alignment.CenterStart,
) {
RectangleShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size70)
.height(sp12),
radius = TangemTheme.dimens.radius3,
)
}
SpacerH(height = TangemTheme.dimens.spacing2)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing2),
contentAlignment = Alignment.CenterStart,
) {
RectangleShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size52)
.height(sp12),
radius = TangemTheme.dimens.radius3,
)
}
}
if (windowSize.widthAtLeast(WindowSizeType.Small)) {
Spacer(Modifier.width(TangemTheme.dimens.spacing10))
Box {
RectangleShimmer(
modifier = Modifier
.align(Alignment.Center)
.width(TangemTheme.dimens.size56)
.height(TangemTheme.dimens.size12),
radius = TangemTheme.dimens.radius3,
)
}
}
}
}
@Preview(showBackground = true, widthDp = 360, name = "normal")
@Preview(showBackground = true, widthDp = 360, name = "normal night", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true, widthDp = 320, name = "small width")
@Composable
private fun Preview() {
TangemThemePreview {
Column(Modifier.background(TangemTheme.colors.background.tertiary)) {
repeat(20) {
MarketsListItemPlaceholder()
}
}
}
}

View file

@ -1,212 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.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.core.ui.components.SpacerH12
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.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM.Companion.TOKEN_LAZY_LIST_ID_SEPARATOR
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,
),
)
SpacerH12()
}
}
@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 { item ->
(item.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

@ -1,85 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.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.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
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.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM
import com.tangem.features.markets.tokenlist.impl.ui.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

@ -1,147 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.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.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
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

@ -1,147 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.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.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
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,116 +0,0 @@
@file:Suppress("MagicNumber")
package com.tangem.features.markets.tokenlist.impl.ui.preview
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import kotlinx.collections.immutable.persistentListOf
internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider<MarketsListItemUM>(
collection = listOf(
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = "",
ratingPosition = "10",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.NEUTRAL,
chartData = null,
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = "$6.23348172384781234 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.DOWN,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = "10",
marketCap = null,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = null,
marketCap = "$6.233 B",
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),
name = "Bitcoin",
currencySymbol = "BTC",
iconUrl = null,
ratingPosition = null,
marketCap = null,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = PriceChangeType.UP,
chartData = MarketChartRawData(
y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0),
),
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
),
),
)

View file

@ -1,45 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
@Immutable
data class MarketsListItemUM(
val id: CryptoCurrency.RawID,
val name: String,
val currencySymbol: String,
val iconUrl: String?,
val ratingPosition: String?,
val marketCap: String?,
val price: Price,
val trendPercentText: String,
val trendType: PriceChangeType,
val chartData: MarketChartRawData?,
val isUnder100kMarketCap: Boolean,
val stakingRate: TextReference?,
val updateTimestamp: Long?,
) {
val chartType: MarketChartLook.Type = when (trendType) {
PriceChangeType.UP -> MarketChartLook.Type.Growing
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral
}
@Immutable
data class Price(
val text: String,
val changeType: PriceChangeType? = null,
)
fun getComposeKey(): String {
return id.value + TOKEN_LAZY_LIST_ID_SEPARATOR + marketCap.orEmpty() + updateTimestamp
}
companion object {
const val TOKEN_LAZY_LIST_ID_SEPARATOR = "@"
}
}

View file

@ -1,65 +0,0 @@
package com.tangem.features.markets.tokenlist.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.model.MarketsNotificationUM
import kotlinx.collections.immutable.ImmutableList
internal data class MarketsListUM(
val list: ListUM,
val searchBar: SearchBarUM,
val selectedSortBy: SortByTypeUM,
val sortByBottomSheet: TangemBottomSheetConfig,
val selectedInterval: TrendInterval,
val onIntervalClick: (TrendInterval) -> Unit,
val onSortByButtonClick: () -> Unit,
val marketsNotificationUM: MarketsNotificationUM?,
) {
val isInSearchMode
get() = searchBar.isActive
enum class TrendInterval(val text: TextReference) {
H24(resourceReference(R.string.markets_selector_interval_24h_title)),
D7(resourceReference(R.string.markets_selector_interval_7d_title)),
M1(resourceReference(R.string.markets_selector_interval_1m_title)),
}
}
enum class SortByTypeUM(val text: TextReference) {
Rating(resourceReference(R.string.markets_sort_by_rating_title)),
Trending(resourceReference(R.string.markets_sort_by_trending_title)),
ExperiencedBuyers(resourceReference(R.string.markets_sort_by_experienced_buyers_title)),
TopGainers(resourceReference(R.string.markets_sort_by_top_gainers_title)),
TopLosers(resourceReference(R.string.markets_sort_by_top_losers_title)),
Staking(resourceReference(R.string.common_staking)),
YieldSupply(resourceReference(R.string.yield_module_earn_sheet_title)),
}
@Immutable
sealed class ListUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val shouldShowUnder100kTokensNotification: Boolean,
val wasUnder100kTokensNotificationHidden: Boolean,
val loadMore: () -> Unit,
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
val onShowTokensUnder100kClicked: () -> Unit,
val triggerScrollReset: StateEvent<Unit>,
val onItemClick: (MarketsListItemUM) -> Unit,
) : ListUM()
data object Loading : ListUM()
data class LoadingError(
val onRetryClicked: () -> Unit,
) : ListUM()
data object SearchNothingFound : ListUM()
}

View file

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