Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-06 12:40:48 +03:00
commit 870c3fa9d3
291 changed files with 4985 additions and 5870 deletions

View file

@ -22,6 +22,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
@ -131,6 +132,14 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
override fun onOpenAllNews() {
innerRouter.push(FeedEntryChildFactory.Child.NewsList)
}
override fun onOpenEarnPage() {
innerRouter.push(
FeedEntryChildFactory.Child.Earn(
params = DefaultEarnComponent.Params(onBackClick = { onChildBack() }),
),
)
}
}
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>> =

View file

@ -10,6 +10,7 @@ import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
@ -21,6 +22,7 @@ internal class FeedEntryChildFactory @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory,
) {
@Serializable
@ -100,6 +102,7 @@ internal class FeedEntryChildFactory @Inject constructor(
DefaultFeedComponent(
appComponentContext = appComponentContext,
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
)
}
is Child.Earn -> {

View file

@ -6,10 +6,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.feed.FeedList
@ -18,10 +24,18 @@ import com.tangem.features.feed.ui.feed.FeedListHeader
internal class DefaultFeedComponent(
appComponentContext: AppComponentContext,
private val params: FeedParams,
private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val feedComponentModel = getOrCreateModel<FeedComponentModel, FeedParams>(params = params)
private val bottomSheetSlot = childSlot(
source = feedComponentModel.bottomSheetNavigation,
serializer = FeedPortfolioRoute.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by feedComponentModel.state.collectAsStateWithLifecycle()
@ -40,11 +54,28 @@ internal class DefaultFeedComponent(
}
}
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by feedComponentModel.state.collectAsStateWithLifecycle()
FeedList(
modifier = modifier,
state = state,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: FeedPortfolioRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is FeedPortfolioRoute.AddToPortfolio -> {
addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioPreselectedDataComponent.Params(
tokenToAdd = config.tokenToAdd,
callback = feedComponentModel.addToPortfolioCallback,
),
)
}
}
data class FeedParams(val feedClickIntents: FeedModelClickIntents)

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.components.feed
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import kotlinx.serialization.Serializable
@Serializable
sealed interface FeedPortfolioRoute {
@Serializable
data class AddToPortfolio(val tokenToAdd: AddToPortfolioPreselectedDataComponent.TokenToAdd) : FeedPortfolioRoute
}

View file

@ -95,6 +95,7 @@ internal class DefaultMarketsTokenDetailsComponent(
tokenName = state.tokenName,
tokenPrice = state.priceText,
backgroundColor = background,
onShareClick = state.onShareClick,
)
}

View file

@ -2,7 +2,9 @@ package com.tangem.features.feed.components.market.details.portfolio.add.impl.di
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager
import dagger.Binds
import dagger.Module
@ -18,4 +20,9 @@ internal interface AddToPortfolioComponentModule {
@Binds
fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory
@Binds
fun bindAddToPortfolioPreselectedDataComponent(
factory: DefaultAddToPortfolioPreselectedDataComponent.Factory,
): AddToPortfolioPreselectedDataComponent.Factory
}

View file

@ -112,8 +112,10 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor(
// suspend until all required data is selected
val (selectedNetworkValue, selectedPortfolioValue) = allRequireForAdd.first()
val isTokenAlreadyAdded = selectedPortfolioValue.account.addedMarketNetworks
.any { it.networkId == selectedNetworkValue.selectedNetwork.networkId }
val isTokenAlreadyAdded = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = selectedPortfolioValue.userWallet.walletId,
currency = selectedNetworkValue.cryptoCurrency,
).isSome()
if (isTokenAlreadyAdded) {
finishSuccessFlow(
@ -165,7 +167,7 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor(
): AvailableToAddData? {
val portfolioData = portfolioFetcher.data.firstOrNull() ?: return null
val availableToOpenWallets = portfolioData.balances.mapNotNull { (walletId, balance) ->
val availableToAddInWallets = portfolioData.balances.mapNotNull { (walletId, balance) ->
val wallet = balance.userWallet
val accounts = balance.accountsBalance.accountStatuses
@ -206,9 +208,9 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor(
)
}.toMap()
if (availableToOpenWallets.isEmpty()) return null
if (availableToAddInWallets.isEmpty()) return null
return AvailableToAddData(availableToAddWallets = availableToOpenWallets)
return AvailableToAddData(availableToAddWallets = availableToAddInWallets)
}
private suspend fun createCryptoCurrency(

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
@ -17,6 +18,7 @@ import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.feed.impl.R
@ -49,6 +51,7 @@ internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifie
start = TangemTheme.dimens.spacing10,
end = TangemTheme.dimens.spacing12,
),
modifier = Modifier.testTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM),
)
PortfolioQuickActions(

View file

@ -1,45 +1,29 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.*
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@ -51,6 +35,7 @@ import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -151,7 +136,8 @@ private fun AnimatedVisibilityScope.QuickActionItem(
onClick()
},
)
.padding(horizontal = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing4),
.padding(horizontal = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing4)
.testTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_QUICK_ACTION_BUTTON),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing18),
) {
@ -168,6 +154,7 @@ private fun AnimatedVisibilityScope.QuickActionItem(
text = state.title.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.testTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_QUICK_ACTION_BUTTON_TITLE),
)
Text(
text = state.description.resolveReference(),
@ -192,6 +179,13 @@ private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) {
shape = CircleShape,
)
.size(TangemTheme.dimens.size32)
.semantics {
contentDescription = if (state is QuickActionUM.Exchange && state.shouldShowBadge) {
"Badge shown"
} else {
"Badge hidden"
}
}
.drawWithContent {
drawContent()
if (state is QuickActionUM.Exchange && state.shouldShowBadge) {
@ -202,7 +196,8 @@ private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) {
) {
Icon(
modifier = Modifier
.requiredSize(TangemTheme.dimens.size16),
.requiredSize(TangemTheme.dimens.size16)
.testTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_QUICK_ACTION_BUTTON_ICON),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors.button.primary,

View file

@ -4,13 +4,24 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnFilterByNetworkBottomSheetContentUM
import com.tangem.features.feed.ui.earn.state.EarnFilterByTypeBottomSheetContentUM
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.earn.state.EarnUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@Stable
@ -22,18 +33,213 @@ internal class EarnModel @Inject constructor(
private val params = paramsContainer.require<DefaultEarnComponent.Params>()
// TODO [REDACTED_TASK_KEY] remove mocks
private val mockNetworks: ImmutableList<EarnFilterNetworkUM> = persistentListOf(
EarnFilterNetworkUM.AllNetworks(
text = resourceReference(R.string.earn_filter_all_networks),
isSelected = false,
),
EarnFilterNetworkUM.MyNetworks(
text = resourceReference(R.string.earn_filter_my_networks),
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "ethereum",
text = stringReference("Ethereum"),
symbol = stringReference("ETH"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/ethereum.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "polygon",
text = stringReference("Polygon"),
symbol = stringReference("MATIC"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/matic-network.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "base",
text = stringReference("Base"),
symbol = stringReference("BASE"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/base.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "avalanche",
text = stringReference("Avalance"),
symbol = stringReference("AVAX"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/avalanche-2.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "cardano",
text = stringReference("Cardano"),
symbol = stringReference("ADA"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/cardano.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "bsc",
text = stringReference("Bitcoin Smart Chain"),
symbol = stringReference("BSC"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/binancecoin.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "bitcoin",
text = stringReference("Bitcoin"),
symbol = stringReference("BTC"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/bitcoin.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "solana",
text = stringReference("Solana"),
symbol = stringReference("SOL"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/solana.png",
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "ton",
text = stringReference("Ton"),
symbol = stringReference("TON"),
iconUrl = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/the-open-network.png",
isSelected = false,
),
)
val state: StateFlow<EarnUM>
field = MutableStateFlow(createInitialState())
private fun createInitialState(): EarnUM = EarnUM(
mostlyUsed = EarnListUM.Loading,
bestOpportunities = EarnListUM.Loading,
selectedNetworkFilter = null,
selectedTypeFilter = null,
networkFilters = persistentListOf(),
typeFilters = persistentListOf(),
onBackClick = params.onBackClick,
onNetworkFilterClick = { },
onTypeFilterClick = { },
)
private fun createInitialState(): EarnUM {
val defaultNetworkFilter = mockNetworks.first()
val defaultTypeFilter = EarnFilterTypeUM.All
return EarnUM(
mostlyUsed = EarnListUM.Loading,
bestOpportunities = EarnBestOpportunitiesUM.Loading,
selectedTypeFilter = defaultTypeFilter,
selectedNetworkFilter = defaultNetworkFilter,
filterByTypeBottomSheet = createTypeFilterBottomSheetConfig(defaultTypeFilter),
filterByNetworkBottomSheet = createNetworkFilterBottomSheetConfig(defaultNetworkFilter),
onBackClick = params.onBackClick,
onNetworkFilterClick = ::onNetworkFilterClick,
onTypeFilterClick = ::onTypeFilterClick,
)
}
private fun createTypeFilterBottomSheetConfig(
selectedOption: EarnFilterTypeUM,
isShown: Boolean = false,
): TangemBottomSheetConfig {
return TangemBottomSheetConfig(
isShown = isShown,
onDismissRequest = ::hideTypeFilterBottomSheet,
content = EarnFilterByTypeBottomSheetContentUM(
selectedOption = selectedOption,
onOptionClick = ::onTypeFilterOptionClick,
),
)
}
private fun createNetworkFilterBottomSheetConfig(
selectedNetwork: EarnFilterNetworkUM,
isShown: Boolean = false,
): TangemBottomSheetConfig {
val updatedNetworks = mockNetworks.map { network ->
when {
network is EarnFilterNetworkUM.AllNetworks && selectedNetwork is EarnFilterNetworkUM.AllNetworks ->
network.copy(isSelected = true)
network is EarnFilterNetworkUM.MyNetworks && selectedNetwork is EarnFilterNetworkUM.MyNetworks ->
network.copy(isSelected = true)
network is EarnFilterNetworkUM.Network && selectedNetwork is EarnFilterNetworkUM.Network &&
network.id == selectedNetwork.id ->
network.copy(isSelected = true)
else -> network
}
}
return TangemBottomSheetConfig(
isShown = isShown,
onDismissRequest = ::hideNetworkFilterBottomSheet,
content = EarnFilterByNetworkBottomSheetContentUM(
selectedNetwork = selectedNetwork,
networks = persistentListOf(*updatedNetworks.toTypedArray()),
onOptionClick = ::onNetworkFilterOptionClick,
),
)
}
private fun onTypeFilterClick() {
state.update { currentState ->
currentState.copy(
filterByTypeBottomSheet = createTypeFilterBottomSheetConfig(
selectedOption = currentState.selectedTypeFilter,
isShown = true,
),
)
}
}
private fun onNetworkFilterClick() {
state.update { currentState ->
currentState.copy(
filterByNetworkBottomSheet = createNetworkFilterBottomSheetConfig(
selectedNetwork = currentState.selectedNetworkFilter,
isShown = true,
),
)
}
}
private fun hideTypeFilterBottomSheet() {
state.update { currentState ->
currentState.copy(
filterByTypeBottomSheet = currentState.filterByTypeBottomSheet.copy(isShown = false),
)
}
}
private fun hideNetworkFilterBottomSheet() {
state.update { currentState ->
currentState.copy(
filterByNetworkBottomSheet = currentState.filterByNetworkBottomSheet.copy(isShown = false),
)
}
}
private fun onTypeFilterOptionClick(type: EarnFilterTypeUM) {
state.update { currentState ->
currentState.copy(
selectedTypeFilter = type,
filterByTypeBottomSheet = createTypeFilterBottomSheetConfig(selectedOption = type),
)
}
}
private fun onNetworkFilterOptionClick(network: EarnFilterNetworkUM) {
state.update { currentState ->
currentState.copy(
selectedNetworkFilter = network,
filterByNetworkBottomSheet = createNetworkFilterBottomSheetConfig(selectedNetwork = network),
)
}
}
// TODO [REDACTED_TASK_KEY] remove
@Suppress("UnusedPrivateMember")
private fun onClearFiltersClick() {
val defaultNetworkFilter = mockNetworks.first()
val defaultTypeFilter = EarnFilterTypeUM.All
state.update { currentState ->
currentState.copy(
selectedTypeFilter = defaultTypeFilter,
selectedNetworkFilter = defaultNetworkFilter,
filterByTypeBottomSheet = createTypeFilterBottomSheetConfig(selectedOption = defaultTypeFilter),
filterByNetworkBottomSheet = createNetworkFilterBottomSheetConfig(
selectedNetwork = defaultNetworkFilter,
),
)
}
}
}

View file

@ -2,6 +2,11 @@ package com.tangem.features.feed.model.feed
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -12,20 +17,28 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.earn.usecase.FetchTopEarnTokensUseCase
import com.tangem.domain.earn.usecase.GetTopEarnTokensUseCase
import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.FeedPortfolioRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager
import com.tangem.features.feed.model.feed.state.FeedStateController
import com.tangem.features.feed.model.feed.state.transformers.UpdateGlobalFeedStateTransformer
import com.tangem.features.feed.model.feed.state.transformers.UpdateMarketChartsTransformer
import com.tangem.features.feed.model.feed.state.transformers.UpdateTrendingNewsStateTransformer
import com.tangem.features.feed.model.feed.state.transformers.*
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -41,22 +54,26 @@ import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class FeedComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase,
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val stateController: FeedStateController,
private val feedFeatureToggle: FeedFeatureToggle,
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
private val appRouter: AppRouter,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>()
private var quotesUpdateJob: Job? = null
private val params = paramsContainer.require<DefaultFeedComponent.FeedParams>()
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
@ -72,7 +89,22 @@ internal class FeedComponentModel @Inject constructor(
dispatchers = dispatchers,
)
internal val state: StateFlow<FeedListUM>
val bottomSheetNavigation: SlotNavigation<FeedPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioPreselectedDataComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) {
bottomSheetNavigation.dismiss()
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = walletId,
currency = addedToken,
),
)
}
}
val state: StateFlow<FeedListUM>
get() = stateController.uiState
val isVisibleOnScreen = MutableStateFlow(false)
@ -81,29 +113,38 @@ internal class FeedComponentModel @Inject constructor(
initializeState()
updateCallbacks()
fetchTrendingNews()
fetchEarnData()
fetchCharts()
subscribeOnCurrencyUpdate()
loadCharts()
subscribeOnDataState()
}
private fun subscribeOnDataState() {
modelScope.launch(dispatchers.default) {
combine(
flow = marketsBatchFlowManager.itemsByOrder,
flow2 = marketsBatchFlowManager.loadingStatesByOrder,
flow3 = marketsBatchFlowManager.errorStatesByOrder,
flow4 = manageTrendingNewsUseCase.observeTrendingNews(),
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult ->
flow5 = getTopEarnTokensUseCase(),
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult, earnResult ->
val globalStateTransformer = UpdateGlobalFeedStateTransformer(
loadingStatesByOrder = loadingStatesByOrder,
errorStatesByOrder = errorStatesByOrder,
trendingNewsResult = trendingNewsResult,
earnResult = earnResult,
onRetryClicked = {
modelScope.launch(dispatchers.default) {
stateController.update { currentState ->
currentState.copy(globalState = GlobalFeedState.Loading)
}
fetchTrendingNewsUseCase.invoke()
fetchTrendingNews()
fetchEarnData()
marketsBatchFlowManager.reloadAll()
}
},
analyticsEventHandler = analyticsEventHandler,
feedFeatureToggle = feedFeatureToggle,
)
val currentState = stateController.value
@ -123,13 +164,15 @@ internal class FeedComponentModel @Inject constructor(
),
UpdateTrendingNewsStateTransformer(
result = trendingNewsResult,
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
}
},
onRetryClicked = ::fetchTrendingNews,
analyticsEventHandler = analyticsEventHandler,
),
UpdateEarnStateTransformer(
isEarnEnabled = feedFeatureToggle.isEarnBlockEnabled,
onItemClick = ::handleEarnTokenClick,
onRetryClick = ::fetchEarnData,
earnResult = earnResult,
),
)
}
@ -144,12 +187,6 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun fetchTrendingNews() {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase()
}
}
private fun subscribeOnCurrencyUpdate() {
modelScope.launch(dispatchers.default) {
currentAppCurrency.drop(1).collect {
@ -158,7 +195,21 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun loadCharts() {
private fun fetchTrendingNews() {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase()
}
}
private fun fetchEarnData() {
if (!feedFeatureToggle.isEarnBlockEnabled) return
modelScope.launch(dispatchers.default) {
stateController.update(UpdateEarnLoadingStateTransformer())
fetchTopEarnTokensUseCase()
}
}
private fun fetchCharts() {
modelScope.launch(dispatchers.default) {
TokenMarketListConfig.Order.entries.forEach { order ->
marketsBatchFlowManager.getOnLastBatchLoadedSuccessFlow(order)?.collect { batchKey ->
@ -198,6 +249,7 @@ internal class FeedComponentModel @Inject constructor(
onSliderEndReached = {
analyticsEventHandler.send(FeedAnalyticsEvent.NewsCarouselEndReached())
},
onOpenEarnPageClick = params.feedClickIntents::onOpenEarnPage,
),
news = NewsUM(
content = persistentListOf(),
@ -214,14 +266,41 @@ internal class FeedComponentModel @Inject constructor(
currentSortByType = SortByTypeUM.TopGainers,
),
globalState = GlobalFeedState.Loading,
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
null
},
)
}
private fun getCurrentDate(): String {
val localDate = DateTime(DateTime.now(), DateTimeZone.getDefault())
return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate)
private fun startQuotesUpdateTimer() {
quotesUpdateJob?.cancel()
quotesUpdateJob = modelScope.launch {
while (true) {
delay(DELAY_TO_FETCH_QUOTES)
isVisibleOnScreen.first { it }
marketsBatchFlowManager.updateQuotes()
}
}
}
private fun updateCallbacks() {
stateController.update { feedListUM ->
feedListUM.copy(
feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::handleSortTypeClicked,
onMarketItemClick = ::handleMarketItemClicked,
onMarketOpenClick = ::handleMarketOpenClicked,
onArticleClick = ::handleArticleClicked,
onOpenAllNews = ::handleOpenAllNews,
onOpenEarnPageClick = params.feedClickIntents::onOpenEarnPage,
),
)
}
}
/* start of clicks area */
private fun handleSortTypeClicked(sortByType: SortByTypeUM) {
stateController.update { currentState ->
val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) ->
@ -249,31 +328,6 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun startQuotesUpdateTimer() {
quotesUpdateJob?.cancel()
quotesUpdateJob = modelScope.launch {
while (true) {
delay(DELAY_TO_FETCH_QUOTES)
isVisibleOnScreen.first { it }
marketsBatchFlowManager.updateQuotes()
}
}
}
private fun updateCallbacks() {
stateController.update { feedListUM ->
feedListUM.copy(
feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::handleSortTypeClicked,
onMarketItemClick = ::handleMarketItemClicked,
onMarketOpenClick = ::handleMarketOpenClicked,
onArticleClick = ::handleArticleClicked,
onOpenAllNews = ::handleOpenAllNews,
),
)
}
}
private fun handleMarketItemClicked(item: MarketsListItemUM) {
val tokenMarket = marketsBatchFlowManager.getTokenMarketById(item.id)
if (tokenMarket != null) {
@ -342,6 +396,26 @@ internal class FeedComponentModel @Inject constructor(
params.feedClickIntents.onOpenAllNews()
}
private fun handleEarnTokenClick(earnTokenWithCurrency: EarnTokenWithCurrency) {
bottomSheetNavigation.activate(
FeedPortfolioRoute.AddToPortfolio(
tokenToAdd = AddToPortfolioPreselectedDataComponent.TokenToAdd(
network = TokenMarketInfo.Network(
networkId = earnTokenWithCurrency.earnToken.networkId,
isExchangeable = false,
contractAddress = earnTokenWithCurrency.earnToken.tokenAddress,
decimalCount = earnTokenWithCurrency.earnToken.decimalCount,
),
id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId),
name = earnTokenWithCurrency.earnToken.tokenName,
symbol = earnTokenWithCurrency.earnToken.tokenSymbol,
),
),
)
}
/* end of clicks area */
/* start of utils area */
private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order {
return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating
@ -354,6 +428,12 @@ internal class FeedComponentModel @Inject constructor(
}
}
private fun getCurrentDate(): String {
val localDate = DateTime(DateTime.now(), DateTimeZone.getDefault())
return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate)
}
/* end of utils area */
companion object {
private const val DELAY_TO_FETCH_QUOTES = 60_000L
}

View file

@ -27,4 +27,6 @@ internal interface FeedModelClickIntents {
paginationConfig: NewsListConfig? = null,
)
fun onOpenAllNews()
fun onOpenEarnPage()
}

View file

@ -1,8 +1,11 @@
package com.tangem.features.feed.model.feed.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.*
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.collections.immutable.persistentListOf
@ -13,7 +16,9 @@ import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class FeedStateController @Inject constructor() {
internal class FeedStateController @Inject constructor(
private val feedFeatureToggle: FeedFeatureToggle,
) {
private val mutableUiState: MutableStateFlow<FeedListUM> = MutableStateFlow(value = getInitialState())
@ -33,15 +38,11 @@ internal class FeedStateController @Inject constructor() {
transformers.forEach { mutableUiState.update(function = it::transform) }
}
fun clear() {
mutableUiState.update { getInitialState() }
}
private fun getInitialState(): FeedListUM {
return FeedListUM(
currentDate = "",
feedListSearchBar = FeedListSearchBar(
placeholderText = com.tangem.core.ui.extensions.TextReference.EMPTY,
placeholderText = TextReference.EMPTY,
onBarClick = {},
),
feedListCallbacks = FeedListCallbacks(
@ -53,6 +54,7 @@ internal class FeedStateController @Inject constructor() {
onSortTypeClick = {},
onSliderScroll = {},
onSliderEndReached = {},
onOpenEarnPageClick = {},
),
news = NewsUM(
content = persistentListOf(),
@ -65,6 +67,11 @@ internal class FeedStateController @Inject constructor() {
currentSortByType = SortByTypeUM.Trending,
),
globalState = GlobalFeedState.Loading,
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
null
},
)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.FeedListUM
internal class UpdateEarnLoadingStateTransformer : FeedListUMTransformer {
override fun transform(prevState: FeedListUM): FeedListUM {
return prevState.copy(earnListUM = EarnListUM.Loading)
}
}

View file

@ -0,0 +1,111 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.models.earn.EarnRewardType
import com.tangem.domain.models.earn.EarnTokenWithCurrency
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.earn.EarnType
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class UpdateEarnStateTransformer(
private val isEarnEnabled: Boolean,
private val onItemClick: (EarnTokenWithCurrency) -> Unit,
private val onRetryClick: () -> Unit,
private val earnResult: EarnTopToken?,
) : FeedListUMTransformer {
private val earnTokenWithCurrencyConverter = EarnTokenWithCurrencyConverter(onItemClick = onItemClick)
override fun transform(prevState: FeedListUM): FeedListUM {
if (!isEarnEnabled) return handleEmptyState(prevState)
return when (earnResult) {
null -> {
handleEmptyState(prevState)
}
else -> {
earnResult.fold(
ifLeft = {
handleErrorState(prevState)
},
ifRight = { earnTokensWithCurrency ->
handleDataState(
currentState = prevState,
earnTokensWithCurrency = earnTokensWithCurrency,
)
},
)
}
}
}
private fun handleEmptyState(currentState: FeedListUM): FeedListUM {
return currentState.copy(earnListUM = null)
}
private fun handleErrorState(currentState: FeedListUM): FeedListUM {
return currentState.copy(earnListUM = EarnListUM.Error(onRetryClick))
}
private fun handleDataState(
currentState: FeedListUM,
earnTokensWithCurrency: List<EarnTokenWithCurrency>,
): FeedListUM {
return currentState.copy(
earnListUM = EarnListUM.Content(
items = earnTokensWithCurrency
.sortedWith(
compareByDescending<EarnTokenWithCurrency> {
it.earnToken.apy.toBigDecimalOrNull() ?: BigDecimal.ZERO
}.thenBy { it.earnToken.tokenName },
)
.map { earnToken ->
earnTokenWithCurrencyConverter.convert(earnToken)
}
.toPersistentList(),
),
)
}
private class EarnTokenWithCurrencyConverter(
private val onItemClick: (EarnTokenWithCurrency) -> Unit,
) : Converter<EarnTokenWithCurrency, EarnListItemUM> {
override fun convert(value: EarnTokenWithCurrency): EarnListItemUM {
return EarnListItemUM(
network = TextReference.Str(value.networkName),
symbol = TextReference.Str(value.earnToken.tokenSymbol),
tokenName = TextReference.Str(value.earnToken.tokenName),
currencyIconState = CryptoCurrencyToIconStateConverter().convert(value.cryptoCurrency),
earnValue = when (value.earnToken.rewardType) {
EarnRewardType.APR -> TextReference.Res(
id = R.string.staking_apr_earn_badge,
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
EarnRewardType.APY -> TextReference.Res(
id = R.string.yield_module_earn_badge,
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
},
earnType = when (value.earnToken.type) {
EarnType.STAKING -> TextReference.Res(R.string.common_staking)
EarnType.YIELD -> TextReference.Res(R.string.common_yield_mode)
},
onItemClick = { onItemClick(value) },
)
}
private fun convertPercent(value: String): TextReference {
val percent = BigDecimal(value).format { percent(withPercentSign = false) }
return TextReference.Str(percent)
}
}
}

View file

@ -1,57 +1,58 @@
package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.GlobalFeedState
import com.tangem.features.feed.ui.feed.state.NewsUMState
@Suppress("LongParameterList")
internal class UpdateGlobalFeedStateTransformer(
private val loadingStatesByOrder: Map<SortByTypeUM, Boolean>,
private val errorStatesByOrder: Map<SortByTypeUM, Throwable?>,
private val trendingNewsResult: TrendingNews,
private val earnResult: EarnTopToken?,
private val onRetryClicked: () -> Unit,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feedFeatureToggle: FeedFeatureToggle,
) : FeedListUMTransformer {
@Suppress("CyclomaticComplexMethod")
override fun transform(prevState: FeedListUM): FeedListUM {
val previousGlobalState = prevState.globalState
val blockStates = buildList {
add(getNewsState(prevState))
add(getChartsState())
if (feedFeatureToggle.isEarnBlockEnabled) {
add(getEarnState(prevState))
}
}
val isNewsLoading = prevState.news.newsUMState == NewsUMState.LOADING
val isNewsError = trendingNewsResult is TrendingNews.Error ||
prevState.news.newsUMState == NewsUMState.ERROR
val newGlobalState = when {
blockStates.all { it == BlockState.ERROR } -> {
if (prevState.globalState !is GlobalFeedState.Error) {
sendErrorAnalytics()
}
GlobalFeedState.Error(onRetryClicked = onRetryClicked)
}
blockStates.any { it == BlockState.LOADING } -> GlobalFeedState.Loading
else -> GlobalFeedState.Content
}
return prevState.copy(globalState = newGlobalState)
}
private fun areAllChartsError(): Boolean {
val hasLoadingInCharts = loadingStatesByOrder.values.any { it }
val hasErrorInMarketChart = errorStatesByOrder[SortByTypeUM.Rating] != null
val hasErrorInMarketPulseChart = errorStatesByOrder
.any { it.key != SortByTypeUM.Rating && it.value != null }
val areAllChartsLoading = loadingStatesByOrder.values.isNotEmpty() && loadingStatesByOrder.values.all { it }
val areAllChartsError = when {
hasErrorInMarketChart && hasErrorInMarketPulseChart -> true
hasErrorInMarketPulseChart && hasLoadingInCharts -> true
hasErrorInMarketChart && hasLoadingInCharts -> true
else -> false
}
val newGlobalState = when {
isNewsError && areAllChartsLoading -> GlobalFeedState.Loading
isNewsError && areAllChartsError -> {
if (previousGlobalState !is GlobalFeedState.Error) {
sendErrorAnalytics()
}
GlobalFeedState.Error(
onRetryClicked = onRetryClicked,
)
}
isNewsLoading && areAllChartsError -> GlobalFeedState.Loading
isNewsLoading && areAllChartsLoading -> GlobalFeedState.Loading
else -> GlobalFeedState.Content
}
return prevState.copy(globalState = newGlobalState)
return hasErrorInMarketChart && hasErrorInMarketPulseChart ||
hasErrorInMarketPulseChart && hasLoadingInCharts ||
hasErrorInMarketChart && hasLoadingInCharts
}
private fun sendErrorAnalytics() {
@ -59,4 +60,38 @@ internal class UpdateGlobalFeedStateTransformer(
FeedAnalyticsEvent.AllWidgetsLoadError(),
)
}
private fun getNewsState(prevState: FeedListUM): BlockState {
val isError = trendingNewsResult is TrendingNews.Error || prevState.news.newsUMState == NewsUMState.ERROR
if (isError) return BlockState.ERROR
val isLoading = prevState.news.newsUMState == NewsUMState.LOADING && trendingNewsResult !is TrendingNews.Data
if (isLoading) return BlockState.LOADING
return BlockState.CONTENT
}
private fun getChartsState(): BlockState {
if (areAllChartsError()) return BlockState.ERROR
val isLoading = loadingStatesByOrder.values.isNotEmpty() && loadingStatesByOrder.values.all { it }
if (isLoading) return BlockState.LOADING
return BlockState.CONTENT
}
private fun getEarnState(prevState: FeedListUM): BlockState {
val isError = when (earnResult) {
null -> false
else -> earnResult.isLeft()
}
if (isError) return BlockState.ERROR
val isLoading = prevState.earnListUM is EarnListUM.Loading && earnResult == null
if (isLoading) return BlockState.LOADING
return BlockState.CONTENT
}
private enum class BlockState { LOADING, ERROR, CONTENT }
}

View file

@ -11,6 +11,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
@ -83,6 +84,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val excludedBlockchains: ExcludedBlockchains,
private val urlOpener: UrlOpener,
private val getNewsUseCase: GetNewsUseCase,
private val shareManager: ShareManager,
) : Model() {
private val quotesJob = JobHolder()
@ -247,6 +249,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
onFirstVisible = {},
onScroll = {},
),
onShareClick = ::onShareClick,
),
)
@ -662,6 +665,13 @@ internal class MarketsTokenDetailsModel @Inject constructor(
}
}
private fun onShareClick() {
val tokenId = params.token.id.value
val shareUrl = "$CRYPTOCURRENCIES_BASE_URL$tokenId"
shareManager.shareText(shareUrl)
analyticsEventHandler.send(analyticsEventBuilder.shareClicked())
}
private fun onListedOnClick(exchangesCount: Int) {
modelScope.launch {
analyticsEventHandler.send(analyticsEventBuilder.exchangesScreenOpened())
@ -725,5 +735,6 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private companion object {
const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L
const val RELATED_NEWS_LIMIT = 10
const val CRYPTOCURRENCIES_BASE_URL = "https://tangem.com/en/cryptocurrencies/"
}
}

View file

@ -65,6 +65,11 @@ internal class MarketDetailsAnalyticsEvent(
"Provider" to provider,
),
)
fun shareClicked() = MarketDetailsAnalyticsEvent(
event = "Button - Share",
params = mapOf("Token" to token.symbol),
)
}
enum class IntervalType(val source: String) {

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.earn
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@ -9,12 +10,16 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Icon
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.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
@ -37,10 +42,16 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.ui.earn.components.EarnFilterByNetworkBottomSheet
import com.tangem.features.feed.ui.earn.components.EarnFilterByTypeBottomSheet
import com.tangem.features.feed.ui.earn.components.EarnItemPlaceholder
import com.tangem.features.feed.ui.earn.components.EarnListItem
import com.tangem.features.feed.ui.earn.components.MostlyUsedPlaceholder
import com.tangem.features.feed.ui.earn.state.EarnFilterUM
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.earn.state.EarnUM
@ -51,6 +62,10 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
EarnFilterByTypeBottomSheet(config = state.filterByTypeBottomSheet)
EarnFilterByNetworkBottomSheet(config = state.filterByNetworkBottomSheet)
LazyColumn(
modifier = modifier
.fillMaxSize()
@ -79,8 +94,8 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
SpacerH(12.dp)
BestOpportunitiesFilters(
state = state.bestOpportunities,
selectedNetworkFilter = state.selectedNetworkFilter,
selectedTypeFilter = state.selectedTypeFilter,
selectedNetworkFilterText = state.selectedNetworkFilterText,
selectedTypeFilterText = state.selectedTypeFilterText,
onNetworkFilterClick = state.onNetworkFilterClick,
onTypeFilterClick = state.onTypeFilterClick,
)
@ -94,40 +109,42 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
@Composable
private fun MostlyUsedContent(state: EarnListUM) {
when (state) {
is EarnListUM.Loading -> {
MostlyUsedPlaceholder()
}
is EarnListUM.Content -> {
LazyRow(
contentPadding = PaddingValues(
horizontal = 16.dp,
vertical = 12.dp,
),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = state.items,
key = { "${it.tokenName}-${it.network}" },
) { item ->
MostlyUsedCard(
item = item,
onClick = item.onItemClick,
)
}
AnimatedContent(state) { st ->
when (st) {
is EarnListUM.Loading -> {
MostlyUsedPlaceholder()
}
}
is EarnListUM.Error -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
is EarnListUM.Content -> {
LazyRow(
contentPadding = PaddingValues(
horizontal = 16.dp,
vertical = 12.dp,
),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = state.onRetryClicked)
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = st.items,
key = { "${it.tokenName}-${it.network}" },
) { item ->
MostlyUsedCard(
item = item,
onClick = item.onItemClick,
)
}
}
}
is EarnListUM.Error -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 12.dp,
),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = st.onRetryClicked)
}
}
}
}
@ -184,40 +201,46 @@ private fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier:
@Composable
private fun BestOpportunitiesFilters(
state: EarnListUM,
selectedNetworkFilter: EarnFilterUM?,
selectedTypeFilter: EarnFilterUM?,
state: EarnBestOpportunitiesUM,
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
) {
when (state) {
is EarnListUM.Loading -> {
FilterButtonsShimmer()
}
is EarnListUM.Content -> {
FilterButtons(
selectedNetworkFilter = selectedNetworkFilter,
selectedTypeFilter = selectedTypeFilter,
isEnabled = true,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
}
is EarnListUM.Error -> {
FilterButtons(
selectedNetworkFilter = selectedNetworkFilter,
selectedTypeFilter = selectedTypeFilter,
isEnabled = false,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
AnimatedContent(state) { st ->
when (st) {
is EarnBestOpportunitiesUM.Loading -> {
FilterButtonsShimmer()
}
is EarnBestOpportunitiesUM.EmptyFiltered,
is EarnBestOpportunitiesUM.Content,
-> {
FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = true,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
}
is EarnBestOpportunitiesUM.Empty,
is EarnBestOpportunitiesUM.Error,
-> {
FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = false,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
}
}
}
}
private fun LazyListScope.bestOpportunitiesItems(state: EarnListUM) {
private fun LazyListScope.bestOpportunitiesItems(state: EarnBestOpportunitiesUM) {
when (state) {
is EarnListUM.Loading -> {
is EarnBestOpportunitiesUM.Loading -> {
val lastIndex = PLACEHOLDER_ITEMS_COUNT - 1
items(
count = PLACEHOLDER_ITEMS_COUNT,
@ -233,7 +256,19 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnListUM) {
)
}
}
is EarnListUM.Content -> {
is EarnBestOpportunitiesUM.Empty -> {
item(key = "best_opportunities_empty") {
SpacerH(12.dp)
BestOpportunitiesEmpty()
}
}
is EarnBestOpportunitiesUM.EmptyFiltered -> {
item(key = "best_opportunities_empty_filtered") {
SpacerH(12.dp)
BestOpportunitiesEmptyFiltered(onClearFilterClick = state.onClearFilterClick)
}
}
is EarnBestOpportunitiesUM.Content -> {
if (state.items.isNotEmpty()) {
val lastIndex = state.items.lastIndex
itemsIndexed(
@ -252,7 +287,7 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnListUM) {
}
}
}
is EarnListUM.Error -> {
is EarnBestOpportunitiesUM.Error -> {
item(key = "best_opportunities_error") {
SpacerH(12.dp)
Box(
@ -275,8 +310,8 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnListUM) {
@Composable
private fun FilterButtons(
selectedNetworkFilter: EarnFilterUM?,
selectedTypeFilter: EarnFilterUM?,
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
isEnabled: Boolean,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
@ -287,8 +322,7 @@ private fun FilterButtons(
) {
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedNetworkFilter?.name
?: resourceReference(R.string.earn_filter_all_networks),
text = selectedNetworkFilterText,
onClick = onNetworkFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
@ -299,8 +333,7 @@ private fun FilterButtons(
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedTypeFilter?.name
?: resourceReference(R.string.earn_filter_all_types),
text = selectedTypeFilterText,
onClick = onTypeFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
@ -326,6 +359,66 @@ private fun FilterButtonsShimmer(modifier: Modifier = Modifier) {
}
}
@Composable
private fun BestOpportunitiesEmpty(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(vertical = 32.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
modifier = Modifier
.fillMaxWidth(),
painter = painterResource(R.drawable.ic_empty_64),
contentDescription = null,
tint = Color.Unspecified,
)
SpacerH(24.dp)
Text(
modifier = Modifier
.padding(horizontal = 32.dp),
text = stringResourceSafe(R.string.earn_empty),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun BestOpportunitiesEmptyFiltered(onClearFilterClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(vertical = 32.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.earn_no_results),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(12.dp)
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.earn_clear_filter),
onClick = onClearFilterClick,
),
)
}
}
@Composable
private fun SectionHeader(title: String, modifier: Modifier = Modifier) {
Text(
@ -348,7 +441,7 @@ private fun EarnContentPreview() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
state = EarnUM(
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
previewEarnListItemUM(),
@ -359,7 +452,7 @@ private fun EarnContentPreview() {
),
),
),
bestOpportunities = EarnListUM.Content(
bestOpportunities = EarnBestOpportunitiesUM.Content(
items = persistentListOf(
previewEarnListItemUM(
tokenName = "Cosmos Hub",
@ -373,13 +466,6 @@ private fun EarnContentPreview() {
),
),
),
selectedNetworkFilter = null,
selectedTypeFilter = null,
networkFilters = persistentListOf(),
typeFilters = persistentListOf(),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},
),
)
}
@ -396,16 +482,9 @@ private fun EarnContentLoadingPreview() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
state = EarnUM(
state = previewEarnUM(
mostlyUsed = EarnListUM.Loading,
bestOpportunities = EarnListUM.Loading,
selectedNetworkFilter = null,
selectedTypeFilter = null,
networkFilters = persistentListOf(),
typeFilters = persistentListOf(),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},
bestOpportunities = EarnBestOpportunitiesUM.Loading,
),
)
}
@ -422,7 +501,7 @@ private fun EarnContentErrorPreview() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
state = EarnUM(
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
previewEarnListItemUM(),
@ -433,14 +512,35 @@ private fun EarnContentErrorPreview() {
),
),
),
bestOpportunities = EarnListUM.Error(onRetryClicked = {}),
selectedNetworkFilter = null,
selectedTypeFilter = null,
networkFilters = persistentListOf(),
typeFilters = persistentListOf(),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},
bestOpportunities = EarnBestOpportunitiesUM.Error(onRetryClicked = {}),
),
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun EarnContentEmptyPreview() {
TangemThemePreview {
val background = TangemTheme.colors.background.tertiary
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
previewEarnListItemUM(),
previewEarnListItemUM(
tokenName = "Cosmos",
symbol = "ATOM",
network = "Cosmos",
),
),
),
bestOpportunities = EarnBestOpportunitiesUM.Empty,
),
)
}
@ -469,4 +569,22 @@ private fun previewEarnListItemUM(
onItemClick = {},
)
private fun previewEarnUM(
mostlyUsed: EarnListUM = EarnListUM.Loading,
bestOpportunities: EarnBestOpportunitiesUM = EarnBestOpportunitiesUM.Loading,
): EarnUM = EarnUM(
mostlyUsed = mostlyUsed,
bestOpportunities = bestOpportunities,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(
text = resourceReference(R.string.earn_filter_all_networks),
isSelected = true,
),
filterByTypeBottomSheet = TangemBottomSheetConfig.Empty,
filterByNetworkBottomSheet = TangemBottomSheetConfig.Empty,
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},
)
private const val PLACEHOLDER_ITEMS_COUNT = 8

View file

@ -0,0 +1,245 @@
package com.tangem.features.feed.ui.earn.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.inputrow.InputRowChecked
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.components.rows.RowContentContainer
import com.tangem.core.ui.components.rows.RowText
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.earn.state.EarnFilterByNetworkBottomSheetContentUM
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun EarnFilterByNetworkBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<EarnFilterByNetworkBottomSheetContentUM>(
config = config,
titleText = resourceReference(R.string.earn_filter_by),
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: EarnFilterByNetworkBottomSheetContentUM) {
val allMyNetworks = remember(content) {
content.networks.filterIsInstance<EarnFilterNetworkUM.AllNetworks>() +
content.networks.filterIsInstance<EarnFilterNetworkUM.MyNetworks>()
}
val specificNetworks = remember(content) { content.networks.filterIsInstance<EarnFilterNetworkUM.Network>() }
LazyColumn(
contentPadding = PaddingValues(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
allMyNetworksList(
allMyNetworks = allMyNetworks,
onOptionClicked = content.onOptionClick,
)
if (specificNetworks.isNotEmpty()) {
networksHeader()
specificNetworksList(
specificNetworks = specificNetworks,
onOptionClicked = content.onOptionClick,
)
}
}
}
private fun LazyListScope.allMyNetworksList(
allMyNetworks: List<EarnFilterNetworkUM>,
onOptionClicked: (EarnFilterNetworkUM) -> Unit,
) {
itemsIndexed(
items = allMyNetworks,
key = { _, item ->
when (item) {
is EarnFilterNetworkUM.AllNetworks -> "all_networks"
is EarnFilterNetworkUM.MyNetworks -> "my_networks"
is EarnFilterNetworkUM.Network -> item.id
}
},
) { index, item ->
DividerContainer(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = allMyNetworks.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action)
.clickable { onOptionClicked(item) },
showDivider = index != allMyNetworks.lastIndex,
) {
InputRowChecked(
text = item.text,
checked = item.isSelected,
)
}
}
}
private fun LazyListScope.networksHeader() {
item(key = "networks_header") {
SpacerH(TangemTheme.dimens.spacing16)
Text(
modifier = Modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
),
)
.padding(
start = 12.dp,
end = 12.dp,
top = 12.dp,
bottom = 4.dp,
),
text = stringResourceSafe(id = R.string.earn_filter_networks),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun LazyListScope.specificNetworksList(
specificNetworks: List<EarnFilterNetworkUM.Network>,
onOptionClicked: (EarnFilterNetworkUM) -> Unit,
) {
itemsIndexed(
items = specificNetworks,
key = { _, item -> item.id },
) { index, item ->
DividerContainer(
modifier = Modifier
.height(52.dp)
.roundedShapeItemDecoration(
currentIndex = index + 1,
lastIndex = specificNetworks.lastIndex + 1,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action)
.clickable { onOptionClicked(item) },
showDivider = false,
) {
RowContentContainer(
modifier = Modifier
.heightIn(52.dp)
.padding(horizontal = 12.dp),
icon = {
CoinIcon(
modifier = Modifier.size(22.dp),
url = item.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
},
text = {
RowText(
mainText = item.text.resolveReference(),
secondText = item.symbol.resolveReference(),
accentMainText = true,
accentSecondText = false,
)
},
action = {
if (item.isSelected) {
Icon(
painter = painterResource(R.drawable.ic_check_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
},
)
}
}
}
@Preview(widthDp = 360, heightDp = 800)
@Preview(widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview(
alwaysShowBottomSheets = true,
) {
Box(Modifier.background(TangemTheme.colors.background.secondary)) {
EarnFilterByNetworkBottomSheet(
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = EarnFilterByNetworkBottomSheetContentUM(
selectedNetwork = EarnFilterNetworkUM.AllNetworks(
text = resourceReference(R.string.earn_filter_all_networks),
isSelected = true,
),
networks = persistentListOf(
EarnFilterNetworkUM.AllNetworks(
text = resourceReference(R.string.earn_filter_all_networks),
isSelected = true,
),
EarnFilterNetworkUM.MyNetworks(
text = stringReference("My network"),
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "ethereum",
text = stringReference("Ethereum"),
symbol = stringReference("ETH"),
iconUrl = null,
isSelected = false,
),
EarnFilterNetworkUM.Network(
id = "polygon",
text = stringReference("Polygon"),
symbol = stringReference("MATIC"),
iconUrl = null,
isSelected = false,
),
),
onOptionClick = {},
),
),
)
}
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.features.feed.ui.earn.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.inputrow.InputRowChecked
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.earn.state.EarnFilterByTypeBottomSheetContentUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
@Composable
internal fun EarnFilterByTypeBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<EarnFilterByTypeBottomSheetContentUM>(
config = config,
titleText = resourceReference(R.string.earn_filter_by),
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: EarnFilterByTypeBottomSheetContentUM) {
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
EarnFilterTypeUM.entries.forEachIndexed { index, type ->
DividerContainer(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = EarnFilterTypeUM.entries.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action)
.clickable { content.onOptionClick(type) },
showDivider = index != EarnFilterTypeUM.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)) {
EarnFilterByTypeBottomSheet(
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = EarnFilterByTypeBottomSheetContentUM(
selectedOption = EarnFilterTypeUM.All,
onOptionClick = {},
),
),
)
}
}
}

View file

@ -17,11 +17,11 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
internal fun EarnListPlaceholder(modifier: Modifier = Modifier) {
internal fun EarnListPlaceholder(modifier: Modifier = Modifier, placeholderCount: Int = PLACEHOLDER_ITEMS_COUNT) {
Column(
modifier = modifier.fillMaxSize(),
) {
repeat(PLACEHOLDER_ITEMS_COUNT) {
repeat(placeholderCount) {
EarnItemPlaceholder()
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.feed.ui.earn.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import kotlinx.collections.immutable.ImmutableList
internal data class EarnFilterByNetworkBottomSheetContentUM(
val selectedNetwork: EarnFilterNetworkUM,
val networks: ImmutableList<EarnFilterNetworkUM>,
val onOptionClick: (EarnFilterNetworkUM) -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,8 @@
package com.tangem.features.feed.ui.earn.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
internal data class EarnFilterByTypeBottomSheetContentUM(
val selectedOption: EarnFilterTypeUM,
val onOptionClick: (EarnFilterTypeUM) -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,29 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class EarnFilterNetworkUM {
abstract val text: TextReference
abstract val isSelected: Boolean
data class AllNetworks(
override val text: TextReference,
override val isSelected: Boolean,
) : EarnFilterNetworkUM()
data class MyNetworks(
override val text: TextReference,
override val isSelected: Boolean,
) : EarnFilterNetworkUM()
data class Network(
val id: String,
override val text: TextReference,
val symbol: TextReference,
val iconUrl: String?,
override val isSelected: Boolean,
) : EarnFilterNetworkUM()
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.ui.earn.state
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
internal enum class EarnFilterTypeUM(val text: TextReference) {
All(resourceReference(R.string.earn_filter_all_types)),
Staking(resourceReference(R.string.common_staking)),
YieldMode(resourceReference(R.string.markets_sort_by_yield_mode_title)),
}

View file

@ -12,6 +12,15 @@ internal sealed interface EarnListUM {
data class Error(val onRetryClicked: () -> Unit) : EarnListUM
}
@Immutable
internal sealed interface EarnBestOpportunitiesUM {
data object Loading : EarnBestOpportunitiesUM
data object Empty : EarnBestOpportunitiesUM
data class EmptyFiltered(val onClearFilterClick: () -> Unit) : EarnBestOpportunitiesUM
data class Content(val items: ImmutableList<EarnListItemUM>) : EarnBestOpportunitiesUM
data class Error(val onRetryClicked: () -> Unit) : EarnBestOpportunitiesUM
}
@Immutable
internal data class EarnListItemUM(
val network: TextReference,

View file

@ -1,26 +1,25 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class EarnUM(
val mostlyUsed: EarnListUM,
val bestOpportunities: EarnListUM,
val selectedNetworkFilter: EarnFilterUM?,
val selectedTypeFilter: EarnFilterUM?,
val networkFilters: ImmutableList<EarnFilterUM>,
val typeFilters: ImmutableList<EarnFilterUM>,
val bestOpportunities: EarnBestOpportunitiesUM,
val selectedTypeFilter: EarnFilterTypeUM,
val selectedNetworkFilter: EarnFilterNetworkUM,
val filterByTypeBottomSheet: TangemBottomSheetConfig,
val filterByNetworkBottomSheet: TangemBottomSheetConfig,
val onBackClick: () -> Unit,
val onNetworkFilterClick: () -> Unit,
val onTypeFilterClick: () -> Unit,
)
) {
@Immutable
internal data class EarnFilterUM(
val id: String,
val name: TextReference,
val isSelected: Boolean,
val onClick: () -> Unit,
)
val selectedNetworkFilterText: TextReference
get() = selectedNetworkFilter.text
val selectedTypeFilterText: TextReference
get() = selectedTypeFilter.text
}

View file

@ -4,57 +4,38 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.common.ui.news.ShowMoreArticlesCard
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BaseSearchBarTestTags.SEARCH_BAR
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.feed.components.*
import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.GlobalFeedState
@Composable
internal fun FeedListHeader(
@ -69,7 +50,8 @@ internal fun FeedListHeader(
modifier = modifier
.drawBehind { drawRect(background) }
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
.padding(bottom = 8.dp)
.testTag(SEARCH_BAR),
)
}
@ -164,6 +146,11 @@ private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
trendingArticle = state.trendingArticle,
)
EarnBlock(
onSeeAllClick = state.feedListCallbacks.onOpenEarnPageClick,
earnListUM = state.earnListUM,
)
MarketPulseBlock(
marketChartConfig = state.marketChartConfig,
feedListCallbacks = state.feedListCallbacks,
@ -171,400 +158,6 @@ private fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
}
}
@Composable
private fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedListCallbacks) {
AnimatedContent(
targetState = marketChart,
label = "MarketBlockChartAnimation",
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentChart ->
when (currentChart) {
is MarketChartUM.Content,
MarketChartUM.Loading,
is MarketChartUM.LoadingError,
-> {
Column(modifier = Modifier.fillMaxWidth()) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
)
SpacerH(12.dp)
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = currentChart,
)
SpacerH(32.dp)
}
}
null -> Unit
}
}
}
@Composable
private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
val onSeeAllClick by rememberUpdatedState {
feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType)
}
if (marketChartConfig.marketCharts.isNotEmpty()) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_pulse_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { onSeeAllClick() },
)
LazyRow(
modifier = Modifier.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
state = rememberLazyListState(),
) {
items(
items = marketChartConfig.getFilterPreset(),
key = SortByTypeUM::name,
) { sortByTypeUM ->
FilterChip(
sortByTypeUM = sortByTypeUM,
isSelected = sortByTypeUM == marketChartConfig.currentSortByType,
onClick = { feedListCallbacks.onSortTypeClick(sortByTypeUM) },
)
}
}
SpacerH(12.dp)
AnimatedContent(
targetState = marketChartConfig.currentSortByType,
label = "MarketPulseChartAnimation",
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentSortType ->
marketChartConfig.marketCharts[currentSortType]?.let { chart ->
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = chart,
)
}
}
SpacerH(32.dp)
}
}
@Composable
private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
AnimatedContent(news.newsUMState) { newsUMState ->
when (newsUMState) {
NewsUMState.LOADING -> NewsLoadingBlock()
NewsUMState.CONTENT -> {
if (news.content.isNotEmpty()) {
NewsContentBlock(
feedListCallbacks = feedListCallbacks,
news = news,
trendingArticle = trendingArticle,
)
}
}
NewsUMState.ERROR -> NewsErrorBlock(onRetryClick = news.onRetryClicked)
}
}
}
@Suppress("LongMethod")
@Composable
private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
val listState = rememberLazyListState()
val articlesReadStatus = remember(news.content) {
news.content.map { it.isViewed }
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
}
Column {
Header(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
SpacerW(4.dp)
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20),
contentDescription = null,
)
SpacerW(2.dp)
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle().copy(
brush = Brush.linearGradient(
GRADIENT_START to LinearGradientFirstPart,
GRADIENT_END to LinearGradientSecondPart,
),
),
) {
append(stringResourceSafe(R.string.feed_tangem_ai))
}
},
style = TangemTheme.typography.subtitle1,
)
}
},
onSeeAllClick = { feedListCallbacks.onOpenAllNews(false) },
)
SpacerH(12.dp)
if (trendingArticle != null) {
Column {
ArticleCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
articleConfigUM = trendingArticle,
onArticleClick = { feedListCallbacks.onArticleClick(trendingArticle.id) },
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
SpacerH(12.dp)
}
}
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = listState,
) {
itemsIndexed(
items = news.content,
key = { _, article -> article.id },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
modifier = articleModifier
.heightIn(min = 164.dp)
.width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.width(216.dp)
.heightIn(min = 164.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderEndReached,
),
onClick = { feedListCallbacks.onOpenAllNews(true) },
)
}
}
SpacerH(32.dp)
}
}
@Composable
private fun Header(title: @Composable () -> Unit, onSeeAllClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
title()
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
}
}
@Composable
private fun Charts(
marketChart: MarketChartUM,
onItemClick: (MarketsListItemUM) -> Unit,
modifier: Modifier = Modifier,
) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(modifier = Modifier.fillMaxWidth()) {
when (marketChart) {
MarketChartUM.Loading -> {
repeat(DEFAULT_CHART_SIZE_IN_MARKET) {
MarketsListItemPlaceholder()
}
}
is MarketChartUM.LoadingError -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 35.dp, horizontal = 10.dp),
) {
UnableToLoadData(
onRetryClick = marketChart.onRetryClicked,
modifier = Modifier.fillMaxWidth(),
)
}
}
is MarketChartUM.Content -> {
marketChart.items.fastForEach { chart ->
MarketsListItem(
model = chart,
onClick = { onItemClick(chart) },
)
}
}
}
}
}
}
@Composable
private fun FilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(shape = RoundedCornerShape(12.dp))
.background(
color = if (isSelected) {
TangemTheme.colors.button.primary
} else {
TangemTheme.colors.button.secondary
},
)
.clickable(
onClick = onClick,
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
)
.padding(vertical = 8.dp, horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = sortByTypeUM.text.resolveReference(),
style = TangemTheme.typography.button,
color = if (isSelected) {
TangemTheme.colors.text.primary2
} else {
TangemTheme.colors.text.primary1
},
)
}
}
@Composable
private fun FeedListGlobalError(onRetryClick: () -> Unit, currentDate: String, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
Column(modifier) {
DateBlock(currentDate)
Box(
modifier = Modifier
.fillMaxSize()
.drawBehind { drawRect(background) }
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = onRetryClick)
}
}
}
@Composable
private fun NewsErrorBlock(onRetryClick: () -> Unit) {
Column {
Header(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
}
},
onSeeAllClick = {},
)
SpacerH(12.dp)
BlockCard(
modifier = Modifier.padding(horizontal = 16.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
UnableToLoadData(
onRetryClick = onRetryClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 35.dp, horizontal = 10.dp),
)
}
SpacerH(32.dp)
}
}
@Composable
private fun ColumnScope.DateBlock(currentDate: String) {
SpacerH(20.dp)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = stringResourceSafe(R.string.feed_market_and_news),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = currentDate,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.tertiary,
)
}
private const val DEFAULT_CHART_SIZE_IN_MARKET = 5
private const val FOURTH_ITEM_INDEX = 3
private const val GRADIENT_START = 0f
private const val GRADIENT_END = 0.5f
private val LinearGradientFirstPart = Color(0xFF635EEC)
private val LinearGradientSecondPart = Color(0xFFE05AED)
@Preview(showBackground = true, heightDp = 1500)
@Composable
private fun FeedListPreview() {

View file

@ -0,0 +1,38 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.extensions.TextReference
@Composable
internal fun Header(onSeeAllClick: () -> Unit, isLoading: Boolean = false, title: @Composable () -> Unit) {
AnimatedContent(isLoading) { animatedState ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (animatedState) {
RectangleShimmer(modifier = Modifier.size(width = 104.dp, height = 18.dp))
} else {
title()
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
}
}
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
@Composable
internal fun ChartsFilterChip(sortByTypeUM: SortByTypeUM, isSelected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(shape = RoundedCornerShape(12.dp))
.background(
color = if (isSelected) {
TangemTheme.colors.button.primary
} else {
TangemTheme.colors.button.secondary
},
)
.clickable(
onClick = onClick,
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
)
.padding(vertical = 8.dp, horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = sortByTypeUM.text.resolveReference(),
style = TangemTheme.typography.button,
color = if (isSelected) {
TangemTheme.colors.text.primary2
} else {
TangemTheme.colors.text.primary1
},
)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun DateBlock(currentDate: String) {
SpacerH(20.dp)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = stringResourceSafe(R.string.feed_market_and_news),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
text = currentDate,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.tertiary,
)
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.earn.components.EarnListItem
import com.tangem.features.feed.ui.earn.components.EarnListPlaceholder
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM?, modifier: Modifier = Modifier) {
if (earnListUM == null) return
Column(modifier = modifier) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_earn_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = onSeeAllClick,
isLoading = earnListUM is EarnListUM.Loading,
)
SpacerH(12.dp)
BlockCard(
modifier = Modifier.padding(horizontal = 16.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
AnimatedContent(targetState = earnListUM) { earnListState ->
when (earnListState) {
is EarnListUM.Content -> EarnContentBlock(items = earnListState.items)
is EarnListUM.Error -> EarnErrorBlock(onRetryClick = earnListState.onRetryClicked)
EarnListUM.Loading -> EarnListPlaceholder(placeholderCount = PLACEHOLDER_ITEM_COUNT)
}
}
}
SpacerH(32.dp)
}
}
@Composable
private fun EarnErrorBlock(onRetryClick: () -> Unit) {
UnableToLoadData(
onRetryClick = onRetryClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 35.dp, horizontal = 10.dp),
)
}
@Composable
private fun EarnContentBlock(items: ImmutableList<EarnListItemUM>) {
Column(modifier = Modifier.fillMaxWidth()) {
items.fastForEach { item ->
EarnListItem(item = item)
}
}
}
private const val PLACEHOLDER_ITEM_COUNT = 5

View file

@ -0,0 +1,30 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.res.LocalMainBottomSheetColor
@Composable
internal fun FeedListGlobalError(onRetryClick: () -> Unit, currentDate: String, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
Column(modifier) {
DateBlock(currentDate)
Box(
modifier = Modifier
.fillMaxSize()
.drawBehind { drawRect(background) }
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = onRetryClick)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.feed
package com.tangem.features.feed.ui.feed.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
@ -14,6 +14,8 @@ import com.tangem.common.ui.news.TrendingLoadingArticle
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
@ -94,7 +96,10 @@ internal fun NewsLoadingBlock() {
@Composable
private fun ChartsLoading(modifier: Modifier = Modifier) {
BlockCard(modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(
modifier = Modifier
.fillMaxWidth()

View file

@ -0,0 +1,176 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.feed.state.FeedListCallbacks
import com.tangem.features.feed.ui.feed.state.MarketChartConfig
import com.tangem.features.feed.ui.feed.state.MarketChartUM
@Composable
internal fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedListCallbacks) {
AnimatedContent(
targetState = marketChart,
label = "MarketBlockChartAnimation",
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentChart ->
when (currentChart) {
is MarketChartUM.Content,
MarketChartUM.Loading,
is MarketChartUM.LoadingError,
-> {
Column(modifier = Modifier.fillMaxWidth()) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
)
SpacerH(12.dp)
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = currentChart,
)
SpacerH(32.dp)
}
}
null -> Unit
}
}
}
@Composable
internal fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
val onSeeAllClick by rememberUpdatedState {
feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType)
}
if (marketChartConfig.marketCharts.isNotEmpty()) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_pulse_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { onSeeAllClick() },
)
LazyRow(
modifier = Modifier.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
state = rememberLazyListState(),
) {
items(
items = marketChartConfig.getFilterPreset(),
key = SortByTypeUM::name,
) { sortByTypeUM ->
ChartsFilterChip(
sortByTypeUM = sortByTypeUM,
isSelected = sortByTypeUM == marketChartConfig.currentSortByType,
onClick = { feedListCallbacks.onSortTypeClick(sortByTypeUM) },
)
}
}
SpacerH(12.dp)
AnimatedContent(
targetState = marketChartConfig.currentSortByType,
label = "MarketPulseChartAnimation",
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentSortType ->
marketChartConfig.marketCharts[currentSortType]?.let { chart ->
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = chart,
)
}
}
SpacerH(32.dp)
}
}
@Composable
private fun Charts(
marketChart: MarketChartUM,
onItemClick: (MarketsListItemUM) -> Unit,
modifier: Modifier = Modifier,
) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(modifier = Modifier.fillMaxWidth()) {
when (marketChart) {
MarketChartUM.Loading -> {
repeat(DEFAULT_CHART_SIZE_IN_MARKET) {
MarketsListItemPlaceholder()
}
}
is MarketChartUM.LoadingError -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 35.dp, horizontal = 10.dp),
) {
UnableToLoadData(
onRetryClick = marketChart.onRetryClicked,
modifier = Modifier.fillMaxWidth(),
)
}
}
is MarketChartUM.Content -> {
marketChart.items.fastForEach { chart ->
MarketsListItem(
model = chart,
onClick = { onItemClick(chart) },
)
}
}
}
}
}
}
private const val DEFAULT_CHART_SIZE_IN_MARKET = 5

View file

@ -0,0 +1,203 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.common.ui.news.ShowMoreArticlesCard
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.state.FeedListCallbacks
import com.tangem.features.feed.ui.feed.state.NewsUM
import com.tangem.features.feed.ui.feed.state.NewsUMState
private const val FOURTH_ITEM_INDEX = 3
private const val GRADIENT_START = 0f
private const val GRADIENT_END = 0.5f
private val LinearGradientFirstPart = Color(0xFF635EEC)
private val LinearGradientSecondPart = Color(0xFFE05AED)
@Composable
internal fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
AnimatedContent(news.newsUMState) { newsUMState ->
when (newsUMState) {
NewsUMState.LOADING -> NewsLoadingBlock()
NewsUMState.CONTENT -> {
if (news.content.isNotEmpty()) {
NewsContentBlock(
feedListCallbacks = feedListCallbacks,
news = news,
trendingArticle = trendingArticle,
)
}
}
NewsUMState.ERROR -> NewsErrorBlock(onRetryClick = news.onRetryClicked)
}
}
}
@Suppress("LongMethod")
@Composable
private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
val listState = rememberLazyListState()
val articlesReadStatus = remember(news.content) {
news.content.map { it.isViewed }
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
}
Column {
Header(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
SpacerW(4.dp)
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20),
contentDescription = null,
)
SpacerW(2.dp)
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle().copy(
brush = Brush.linearGradient(
GRADIENT_START to LinearGradientFirstPart,
GRADIENT_END to LinearGradientSecondPart,
),
),
) {
append(stringResourceSafe(R.string.feed_tangem_ai))
}
},
style = TangemTheme.typography.subtitle1,
)
}
},
onSeeAllClick = { feedListCallbacks.onOpenAllNews(false) },
)
SpacerH(12.dp)
if (trendingArticle != null) {
Column {
ArticleCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
articleConfigUM = trendingArticle,
onArticleClick = { feedListCallbacks.onArticleClick(trendingArticle.id) },
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
SpacerH(12.dp)
}
}
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = listState,
) {
itemsIndexed(
items = news.content,
key = { _, article -> article.id },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
modifier = articleModifier
.heightIn(min = 164.dp)
.width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.width(216.dp)
.heightIn(min = 164.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderEndReached,
),
onClick = { feedListCallbacks.onOpenAllNews(true) },
)
}
}
SpacerH(32.dp)
}
}
@Composable
private fun NewsErrorBlock(onRetryClick: () -> Unit) {
Column {
Header(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
}
},
onSeeAllClick = {},
)
SpacerH(12.dp)
BlockCard(
modifier = Modifier.padding(horizontal = 16.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
UnableToLoadData(
onRetryClick = onRetryClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 35.dp, horizontal = 10.dp),
)
}
SpacerH(32.dp)
}
}

View file

@ -3,13 +3,18 @@ package com.tangem.features.feed.ui.feed.preview
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.state.*
import kotlinx.collections.immutable.*
@ -34,6 +39,7 @@ internal object FeedListPreviewDataProvider {
onSortTypeClick = {},
onSliderScroll = {},
onSliderEndReached = {},
onOpenEarnPageClick = {},
),
news = NewsUM(
content = articles.filter { it.isTrending.not() }.toImmutableList(),
@ -45,6 +51,9 @@ internal object FeedListPreviewDataProvider {
marketCharts = createMarketCharts(marketItems, includeErrorState = false),
currentSortByType = SortByTypeUM.TopGainers,
),
earnListUM = EarnListUM.Content(
items = createEarnListItemsUM(),
),
)
}
@ -250,4 +259,25 @@ internal object FeedListPreviewDataProvider {
updateTimestamp = 0,
)
}
private fun createEarnListItemsUM(): ImmutableList<EarnListItemUM> {
return List(5) {
EarnListItemUM(
network = stringReference("Ethereum"),
symbol = stringReference("USDT"),
tokenName = stringReference("TETHER"),
currencyIconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
onItemClick = {},
)
}.toPersistentList()
}
}

View file

@ -1,44 +0,0 @@
package com.tangem.features.feed.ui.feed.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.collections.immutable.ImmutableList
internal data class EarnListUM(
val items: ImmutableList<EarnListItemUM>,
val contentState: EarnListContentState,
)
@Immutable
internal data class EarnListItemUM(
val network: TextReference.Str,
val symbol: String,
val tokenName: String,
val currencyIconState: CurrencyIconState,
val earnValue: EarnValueUM,
val earnType: EarnType,
val onItemClick: () -> Unit,
)
@Immutable
internal data class EarnValueUM(
val percent: SerializedBigDecimal,
val earnValueType: EarnValueType,
)
internal enum class EarnType {
Staking, Yield
}
internal enum class EarnValueType {
APR, APY
}
@Immutable
internal sealed interface EarnListContentState {
data object Loading : EarnListContentState
data object Content : EarnListContentState
data class Error(val onRetryClicked: () -> Unit) : EarnListContentState
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
@ -17,6 +18,7 @@ internal data class FeedListUM(
val trendingArticle: ArticleConfigUM?,
val marketChartConfig: MarketChartConfig,
val globalState: GlobalFeedState = GlobalFeedState.Content,
val earnListUM: EarnListUM?,
)
internal data class FeedListCallbacks(
@ -28,6 +30,7 @@ internal data class FeedListCallbacks(
val onSortTypeClick: (sortBy: SortByTypeUM) -> Unit,
val onSliderScroll: () -> Unit,
val onSliderEndReached: () -> Unit,
val onOpenEarnPageClick: () -> Unit,
)
internal data class FeedListSearchBar(

View file

@ -42,6 +42,7 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.components.*
import com.tangem.core.ui.R as CoreR
import com.tangem.features.feed.ui.market.detailed.preview.MarketsTokenDetailsPreview
import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent
import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent
@ -150,6 +151,7 @@ internal fun MarketsTokenDetailsTopBar(
tokenPrice: String,
isBackButtonEnabled: Boolean,
onBackClick: () -> Unit,
onShareClick: () -> Unit,
) {
TangemTopAppBar(
modifier = Modifier.drawBehind { drawRect(backgroundColor) },
@ -159,6 +161,10 @@ internal fun MarketsTokenDetailsTopBar(
onBackClicked = onBackClick,
enabled = isBackButtonEnabled,
),
endButton = TopAppBarButtonUM.Icon(
iconRes = CoreR.drawable.ic_share_24,
onClicked = onShareClick,
),
)
}

View file

@ -49,6 +49,7 @@ internal object MarketsTokenDetailsPreview {
onFirstVisible = {},
onScroll = {},
),
onShareClick = {},
)
val contentState = MarketsTokenDetailsUM(
@ -141,5 +142,6 @@ internal object MarketsTokenDetailsPreview {
onFirstVisible = {},
onScroll = {},
),
onShareClick = {},
)
}

View file

@ -28,6 +28,7 @@ internal data class MarketsTokenDetailsUM(
val shouldShowPriceSubtitle: Boolean,
val onShouldShowPriceSubtitleChange: (Boolean) -> Unit,
val relatedNews: RelatedNews,
val onShareClick: () -> Unit,
) {
data class ChartState(