Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-22 09:03:01 +01:00
parent cef0618791
commit c6fc619530
76 changed files with 5247 additions and 176 deletions

View file

@ -16,6 +16,9 @@ import com.tangem.features.createwalletselection.CreateWalletSelectionComponent
import com.tangem.features.createwalletstart.CreateWalletStartComponent
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent
@ -38,10 +41,7 @@ import com.tangem.features.staking.api.StakingComponent
import com.tangem.features.swap.SwapComponent
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.ContinueOnboarding
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.Deeplink
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.FromBannerOnMain
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.FromBannerInSettings
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.wallet.WalletEntryComponent
import com.tangem.features.walletconnect.components.WalletConnectEntryComponent
@ -121,6 +121,8 @@ internal class ChildFactory @Inject constructor(
private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory,
private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val feedFeatureToggle: FeedFeatureToggle,
) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -204,21 +206,39 @@ internal class ChildFactory @Inject constructor(
)
}
is AppRoute.MarketsTokenDetails -> {
createComponentChild(
context = context,
params = MarketsTokenDetailsComponent.Params(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
MarketsTokenDetailsComponent.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = marketsTokenDetailsComponentFactory,
)
if (feedFeatureToggle.isFeedEnabled) {
createComponentChild(
context = context,
params = FeedEntryRoute.MarketTokenDetails(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
FeedEntryRoute.MarketTokenDetails.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = feedEntryComponentFactory,
)
} else {
createComponentChild(
context = context,
params = MarketsTokenDetailsComponent.Params(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
MarketsTokenDetailsComponent.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = marketsTokenDetailsComponentFactory,
)
}
}
is AppRoute.Onramp -> {
createComponentChild(

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -85,6 +86,7 @@ private fun TrendingArticle(articleConfigUM: ArticleConfigUM) {
style = TangemTheme.typography.h3,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
SpacerH(8.dp)

View file

@ -15,6 +15,8 @@ import com.tangem.core.ui.res.TangemThemePreview
fun AppBarWithBackButtonAndIcon(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
backButtonEnabled: Boolean = true,
endButtonEnabled: Boolean = true,
text: String? = null,
subtitle: String? = null,
@DrawableRes backIconRes: Int? = null,
@ -30,11 +32,13 @@ fun AppBarWithBackButtonAndIcon(
startButton = TopAppBarButtonUM.Icon(
iconRes = backIconRes ?: R.drawable.ic_back_24,
onClicked = onBackClick,
isEnabled = backButtonEnabled,
),
endButton = if (iconRes != null && onIconClick != null) {
TopAppBarButtonUM.Icon(
iconRes = iconRes,
onClicked = onIconClick,
isEnabled = endButtonEnabled,
)
} else {
null

View file

@ -0,0 +1,33 @@
package com.tangem.core.ui.decompose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
/**
* An interface describing the UI part of a component for a modular BottomSheet.
*
* Designed for use in Decompose components. It separates the UI into a title and content,
* providing access to the [BottomSheetState] to react to changes in the sheet's state (collapsed/expanded).
*/
@Stable
interface ComposableModularBottomSheetContentComponent {
/**
* Renders the title of the bottom sheet.
* @param bottomSheetState The current state of the bottom sheet. This can be used, for example,
* to change navigation buttons (e.g., hiding the "Back" button when collapsed).
*/
@Composable
fun Title(bottomSheetState: State<BottomSheetState>)
/**
* Renders the main content of the bottom sheet.
* @param bottomSheetState The current state of the bottom sheet. Useful for tracking visibility
* (e.g., for analytics or lifecycle effects when the sheet is [BottomSheetState.EXPANDED]).
*/
@Composable
fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier)
}

View file

@ -6,10 +6,12 @@ import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableContentComponent
@Stable
interface FeedEntryComponent {
interface FeedEntryComponent : ComposableContentComponent {
@Composable
fun BottomSheetContent(
@ -18,7 +20,7 @@ interface FeedEntryComponent {
modifier: Modifier,
)
interface Factory {
fun create(context: AppComponentContext): FeedEntryComponent
interface Factory : ComponentFactory<FeedEntryRoute, FeedEntryComponent> {
fun create(context: AppComponentContext, entryRoute: FeedEntryRoute?): FeedEntryComponent
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.feed.entry.components
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import kotlinx.serialization.Serializable
@Serializable
sealed interface FeedEntryRoute {
@Serializable
data class MarketTokenDetails(
val token: TokenMarketParams,
val appCurrency: AppCurrency,
val shouldShowPortfolio: Boolean,
val analyticsParams: AnalyticsParams? = null,
) : FeedEntryRoute {
@Serializable
data class AnalyticsParams(
val blockchain: String?,
val source: String,
)
}
@Serializable
data object MarketTokenList : FeedEntryRoute
}

View file

@ -52,6 +52,8 @@ dependencies {
implementation(projects.domain.notifications.models)
implementation(projects.domain.transaction)
implementation(projects.domain.news)
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.

View file

@ -1,31 +1,31 @@
package com.tangem.features.feed.components
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.popWhile
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.value.Value
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.EntryBottomSheetContent
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.EntryContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -33,6 +33,9 @@ import dagger.assisted.AssistedInject
@Stable
internal class DefaultFeedEntryComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted entryRoute: FeedEntryRoute?,
analyticsEventHandler: AnalyticsEventHandler,
accountsFeatureToggles: AccountsFeatureToggles,
private val feedEntryChildFactory: FeedEntryChildFactory,
) : FeedEntryComponent, AppComponentContext by context {
@ -55,6 +58,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
blockchain = null,
source = "Market",
),
onBackClicked = { onChildBack() },
),
),
)
@ -82,23 +86,26 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
}
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularContentComponent>> = childStack(
key = "main",
source = stackNavigation,
serializer = FeedEntryChildFactory.Child.serializer(),
initialConfiguration = FeedEntryChildFactory.Child.Feed,
handleBackButton = false,
childFactory = { configuration, factoryContext ->
feedEntryChildFactory.createChild(
child = configuration,
appComponentContext = childByContext(
componentContext = factoryContext,
router = innerRouter,
),
feedEntryClickIntents = clickIntents,
)
},
)
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>> =
childStack(
key = "main",
source = stackNavigation,
serializer = FeedEntryChildFactory.Child.serializer(),
initialConfiguration = mapEntryRouteToChild(entryRoute),
handleBackButton = false,
childFactory = { configuration, factoryContext ->
feedEntryChildFactory.createChild(
child = configuration,
appComponentContext = childByContext(
componentContext = factoryContext,
router = innerRouter,
),
feedEntryClickIntents = clickIntents,
analyticsEventHandler = analyticsEventHandler,
accountsFeatureToggles = accountsFeatureToggles,
)
},
)
@Composable
override fun BottomSheetContent(
@ -106,27 +113,73 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
val stackState by stack.subscribeAsState()
BackHandler(enabled = bottomSheetState.value == BottomSheetState.EXPANDED) {
onChildBack()
}
EntryBottomSheetContent(
stackState = stackState,
EntryContent(
bottomSheetState = bottomSheetState,
stackState = stack.subscribeAsState(),
onHeaderSizeChange = onHeaderSizeChange,
isOpenedInBottomSheet = true,
)
}
@Composable
override fun Content(modifier: Modifier) {
val bottomSheetState = remember {
derivedStateOf { BottomSheetState.EXPANDED }
}
BackHandler {
router.pop()
}
EntryContent(
bottomSheetState = bottomSheetState,
stackState = stack.subscribeAsState(),
onHeaderSizeChange = {},
isOpenedInBottomSheet = false,
)
}
private fun onChildBack() {
if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) {
stackNavigation.popWhile { it != FeedEntryChildFactory.Child.Feed }
stackNavigation.pop()
}
}
private fun mapEntryRouteToChild(entryRoute: FeedEntryRoute?): FeedEntryChildFactory.Child {
return when (entryRoute) {
is FeedEntryRoute.MarketTokenDetails -> FeedEntryChildFactory.Child.TokenDetails(
params = DefaultMarketsTokenDetailsComponent.Params(
token = entryRoute.token,
appCurrency = entryRoute.appCurrency,
shouldShowPortfolio = entryRoute.shouldShowPortfolio,
analyticsParams = entryRoute.analyticsParams?.let { params ->
DefaultMarketsTokenDetailsComponent.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
onBackClicked = { router.pop() },
),
)
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
DefaultMarketsTokenListComponent.Params(
onBackClicked = { router.pop() },
onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) },
preselectedSortType = SortByTypeUM.Rating,
shouldAlwaysShowSearchBar = false,
),
)
null -> FeedEntryChildFactory.Child.Feed
}
}
@AssistedFactory
interface Factory : FeedEntryComponent.Factory {
override fun create(context: AppComponentContext): DefaultFeedEntryComponent
override fun create(context: AppComponentContext, entryRoute: FeedEntryRoute?): DefaultFeedEntryComponent
}
}

View file

@ -1,9 +1,11 @@
package com.tangem.features.feed.components
import androidx.compose.runtime.Immutable
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
@ -43,12 +45,16 @@ internal class FeedEntryChildFactory @Inject constructor() {
child: Child,
appComponentContext: AppComponentContext,
feedEntryClickIntents: FeedEntryClickIntents,
): ComposableModularContentComponent {
analyticsEventHandler: AnalyticsEventHandler,
accountsFeatureToggles: AccountsFeatureToggles,
): ComposableModularBottomSheetContentComponent {
return when (child) {
is Child.TokenDetails -> {
DefaultMarketsTokenDetailsComponent(
appComponentContext = appComponentContext,
params = child.params,
analyticsEventHandler = analyticsEventHandler,
accountsFeatureToggles = accountsFeatureToggles,
)
}
is Child.TokenList -> {

View file

@ -1,13 +1,15 @@
package com.tangem.features.feed.components.feed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.feed.FeedList
@ -16,18 +18,18 @@ import com.tangem.features.feed.ui.feed.FeedListHeader
internal class DefaultFeedComponent(
appComponentContext: AppComponentContext,
private val params: FeedParams,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val feedComponentModel = getOrCreateModel<FeedComponentModel, FeedParams>(params = params)
@Composable
override fun Title() {
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by feedComponentModel.state.collectAsStateWithLifecycle()
FeedListHeader(state.feedListSearchBar)
}
@Composable
override fun Content(modifier: Modifier) {
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
LifecycleStartEffect(Unit) {
feedComponentModel.isVisibleOnScreen.value = true
onStopOrDispose {
@ -42,8 +44,5 @@ internal class DefaultFeedComponent(
)
}
@Composable
override fun Footer() = Unit
data class FeedParams(val feedClickIntents: FeedModelClickIntents)
}

View file

@ -1,28 +1,97 @@
package com.tangem.features.feed.components.market.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar
import kotlinx.serialization.Serializable
internal class DefaultMarketsTokenDetailsComponent(
appComponentContext: AppComponentContext,
val params: Params,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
// TODO add portfolio in migrate [REDACTED_JIRA]
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
@Composable
override fun Title() {
// applying l2 compatibility
private val updatedParams = params.copy(
token = params.token.copy(
id = CryptoCurrency.RawID(getTokenIdIfL2Network(params.token.id.value)),
),
)
private val analyticsParams = params.analyticsParams
private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams)
init {
// === Analytics ===
if (analyticsParams != null) {
analyticsEventHandler.send(
MarketDetailsAnalyticsEvent.EventBuilder(
token = params.token,
).screenOpened(
blockchain = analyticsParams.blockchain,
source = analyticsParams.source,
),
)
}
}
@Composable
override fun Content(modifier: Modifier) {
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
MarketsTokenDetailsTopBar(
onBackClick = { params.onBackClicked() },
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
shouldShowPriceSubtitle = state.shouldShowPriceSubtitle,
tokenName = state.tokenName,
tokenPrice = state.priceText,
backgroundColor = TangemTheme.colors.background.tertiary,
)
}
@Composable
override fun Footer() {
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED
}
MarketsTokenDetailsContent(
modifier = modifier,
backgroundColor = LocalMainBottomSheetColor.current.value,
state = state,
isAccountEnabled = accountsFeatureToggles.isFeatureEnabled,
portfolioBlock = {
// TODO add portfolio in migrate [REDACTED_JIRA]
},
)
}
@Serializable
@ -31,6 +100,7 @@ internal class DefaultMarketsTokenDetailsComponent(
val appCurrency: AppCurrency,
val shouldShowPortfolio: Boolean,
val analyticsParams: AnalyticsParams?,
val onBackClicked: () -> Unit,
)
@Serializable

View file

@ -1,56 +1,64 @@
package com.tangem.features.feed.components.market.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.market.list.MarketsList
import com.tangem.features.feed.ui.market.list.TopBarWithSearch
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import kotlinx.serialization.Serializable
internal class DefaultMarketsTokenListComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val model: MarketsListModel = getOrCreateModel<MarketsListModel, Params>(params = params)
@Composable
override fun Title() {
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
TopBarWithSearch(
onBackClick = params.onBackClicked,
onSearchClick = state.onSearchClicked,
marketsSearchBar = state.marketsSearchBar,
buttonsEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
}
@Composable
override fun Content(modifier: Modifier) {
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val bsState by bottomSheetState
val state by model.state.collectAsStateWithLifecycle()
LaunchedEffect(bsState) {
model.containerBottomSheetState.value = bsState
}
MarketsList(
modifier = modifier,
state = state,
)
}
@Composable
override fun Footer() = Unit
@Serializable
data class Params(
val onBackClicked: () -> Unit,

View file

@ -1,23 +1,19 @@
package com.tangem.features.feed.components.news.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
internal class DefaultNewsDetailsComponent(
appComponentContext: AppComponentContext,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
@Composable
override fun Title() {
}
override fun Title(bottomSheetState: State<BottomSheetState>) {}
@Composable
override fun Content(modifier: Modifier) {
}
@Composable
override fun Footer() {
}
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {}
}

View file

@ -1,23 +1,21 @@
package com.tangem.features.feed.components.news.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
internal class DefaultNewsListComponent(
appComponentContext: AppComponentContext,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
@Composable
override fun Title() {
override fun Title(bottomSheetState: State<BottomSheetState>) {
}
@Composable
override fun Content(modifier: Modifier) {
}
@Composable
override fun Footer() {
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
}
}

View file

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

View file

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

View file

@ -17,7 +17,7 @@ import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList

View file

@ -2,7 +2,7 @@ package com.tangem.features.feed.model.feed
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
/**
* Callback interface for feed model navigation actions.

View file

@ -0,0 +1,653 @@
package com.tangem.features.feed.model.market.details
import androidx.compose.runtime.Stable
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.sorted
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.url.UrlOpener
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.markets.*
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.feed.model.market.details.converter.DescriptionConverter
import com.tangem.features.feed.model.market.details.converter.ExchangeItemStateConverter
import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoConverter
import com.tangem.features.feed.model.market.details.formatter.*
import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.joda.time.DateTime
import java.math.BigDecimal
import java.util.Locale
import javax.inject.Inject
@Suppress("LargeClass", "LongParameterList")
@Stable
@ModelScoped
internal class MarketsTokenDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getUserCountryUseCase: GetUserCountryUseCase,
paramsContainer: ParamsContainer,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase,
private val getTokenExchangesUseCase: GetTokenExchangesUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val excludedBlockchains: ExcludedBlockchains,
private val urlOpener: UrlOpener,
) : Model() {
private val quotesJob = JobHolder()
private var userCountry: UserCountry? = null
private val params = paramsContainer.require<DefaultMarketsTokenDetailsComponent.Params>()
private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token)
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = params.appCurrency,
)
private val infoConverter = TokenMarketInfoConverter(
appCurrency = Provider { currentAppCurrency.value },
onInfoClick = { showBottomSheet(it) },
onListedOnClick = ::onListedOnClick,
onLinkClick = { link ->
urlOpener.openUrl(link.url)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.linkClicked(linkTitle = link.title))
},
onSecurityScoreInfoClick = { content ->
showBottomSheet(content)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened())
},
onSecurityScoreProviderLinkClick = { securityScoreProviderUM ->
securityScoreProviderUM.urlData?.fullUrl?.let { url ->
urlOpener.openUrl(url)
}
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(securityScoreProviderUM.name))
},
// === Analytics ===
onPricePerformanceIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance,
interval = interval,
),
)
},
onInsightsIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights,
interval = interval,
),
)
},
needApplyFCARestrictions = Provider {
userCountry.needApplyFCARestrictions()
},
// ==================
)
private val descriptionConverter = DescriptionConverter(
onReadModeClicked = { content ->
showBottomSheet(content)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked())
},
needApplyFCARestrictions = Provider {
userCountry.needApplyFCARestrictions()
},
onGeneratedAINotificationClick = {
modelScope.launch {
sendFeedbackEmailUseCase(
type = FeedbackEmailType.CurrencyDescriptionError(
currencyId = params.token.id.value,
currencyName = params.token.name,
),
)
}
},
)
private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) {
chartData = MarketChartData.NoData.Loading
updateLook { marketChartLook ->
val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType()
marketChartLook.copy(
type = percentChangeType.toChartType(),
xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24),
yAxisFormatter = { value ->
value.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
}
},
)
}
}
private val currentQuotes = MutableStateFlow(
TokenQuotes(
currentPrice = params.token.tokenQuotes.currentPrice,
h24ChangePercent = params.token.tokenQuotes.h24Percent,
weekChangePercent = params.token.tokenQuotes.weekPercent,
monthChangePercent = params.token.tokenQuotes.monthPercent,
m3ChangePercent = null,
m6ChangePercent = null,
yearChangePercent = null,
allTimeChangePercent = null,
),
)
private val currentTokenInfo = MutableStateFlow<TokenMarketInfo?>(null)
private val lastUpdatedTimestamp = MutableStateFlow(DateTime.now().millis)
val isVisibleOnScreen = MutableStateFlow(false)
val networksState = MutableStateFlow<TokenNetworksState>(TokenNetworksState.Loading)
val state = MutableStateFlow(
MarketsTokenDetailsUM(
tokenName = params.token.name,
priceText = params.token.tokenQuotes.currentPrice.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
},
dateTimeText = resourceReference(R.string.common_today),
priceChangePercentText = params.token.tokenQuotes.h24Percent?.format { percent() },
priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(),
iconUrl = params.token.imageUrl,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = chartDataProducer,
onLoadRetryClick = ::onLoadRetryClicked,
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = ::onMarkerPointSelected,
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = ::onSelectedIntervalChange,
isMarkerSet = false,
body = MarketsTokenDetailsUM.Body.Loading,
triggerPriceChange = consumedEvent(),
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
shouldShowPriceSubtitle = false,
onShouldShowPriceSubtitleChange = ::onShouldShowPriceSubtitleChange,
),
)
private val quotesStateUpdater = QuotesStateUpdater(
currentAppCurrency = Provider { currentAppCurrency.value },
state = state,
currentQuotes = currentQuotes,
lastUpdatedTimestamp = lastUpdatedTimestamp,
currentTokenInfo = currentTokenInfo,
onPricePerformanceIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance,
interval = interval,
),
)
},
)
private val loadChartJobHolder = JobHolder()
init {
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
// reload screen if currency changed
modelScope.launch {
currentAppCurrency
.filter { it != params.appCurrency }
.collectLatest {
initialLoad()
}
}
initialLoad()
}
private fun initialLoad() {
loadInfo()
loadChart(state.value.selectedInterval)
modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS)
}
private fun loadQuotes() {
modelScope.launch {
val result = getTokenFullQuotesUseCase(
tokenId = params.token.id,
appCurrency = currentAppCurrency.value,
tokenSymbol = params.token.symbol,
)
result.onRight { res ->
updateQuotes(res)
}
}
}
private fun loadChart(interval: PriceChangeInterval) {
modelScope.launch {
state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy(
chartState = marketsTokenDetailsUM.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
),
)
}
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.NoData.Loading
}
val chart = getTokenPriceChartUseCase.invoke(
appCurrency = currentAppCurrency.value,
interval = interval,
tokenId = params.token.id,
tokenSymbol = params.token.symbol,
preview = false,
)
state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy(
selectedInterval = interval,
chartState = marketsTokenDetailsUM.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
),
)
}
chart
.onRight { updateTokenChart(it) }
.onLeft {
state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy(
chartState = marketsTokenDetailsUM.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.ERROR,
),
body = if (marketsTokenDetailsUM.body is MarketsTokenDetailsUM.Body.Error) {
MarketsTokenDetailsUM.Body.Nothing
} else {
marketsTokenDetailsUM.body
},
)
}
}
}.saveIn(loadChartJobHolder)
}
private suspend fun updateTokenChart(tokenChart: TokenChart) {
val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval)
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
y = tokenChart.priceY.toImmutableList(),
x = tokenChart.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted()
updateLook { marketChartLook ->
marketChartLook.copy(
xAxisFormatter = xAxisFormatter,
type = state.value.priceChangeType.toChartType(),
)
}
}
state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy(
chartState = marketsTokenDetailsUM.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
body = if (marketsTokenDetailsUM.body is MarketsTokenDetailsUM.Body.Nothing) {
MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked)
} else {
marketsTokenDetailsUM.body
},
)
}
}
private fun loadInfo() {
state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy(
body = MarketsTokenDetailsUM.Body.Loading,
)
}
modelScope.launch {
val tokenMarketInfo = getTokenMarketInfoUseCase(
appCurrency = currentAppCurrency.value,
tokenId = params.token.id,
tokenSymbol = params.token.symbol,
)
tokenMarketInfo.fold(
ifRight = { result -> updateInfo(result) },
ifLeft = {
state.update { marketsTokenDetailsUM ->
if (marketsTokenDetailsUM.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) {
marketsTokenDetailsUM.copy(
body = MarketsTokenDetailsUM.Body.Error(
onLoadRetryClick = ::onLoadRetryClicked,
),
)
} else {
marketsTokenDetailsUM.copy(
body = MarketsTokenDetailsUM.Body.Nothing,
)
}
}
},
)
}
}
private fun updateInfo(newInfo: TokenMarketInfo) {
lastUpdatedTimestamp.value = DateTime.now().millis
currentTokenInfo.value = newInfo
currentQuotes.value = newInfo.quotes
val percent = newInfo.quotes.getPercentByInterval(interval = state.value.selectedInterval)
state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy(
priceText = newInfo.quotes.currentPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
},
priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval(
interval = marketsTokenDetailsUM.selectedInterval,
),
priceChangeType = percent.percentChangeType(),
body = MarketsTokenDetailsUM.Body.Content(
description = descriptionConverter.convert(newInfo),
infoBlocks = infoConverter.convert(newInfo),
),
)
}
val isAllWalletsIsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = newInfo.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = isAllWalletsIsHot,
)
}
networksState.value = if (networks.isNullOrEmpty()) {
TokenNetworksState.NoNetworksAvailable
} else {
TokenNetworksState.NetworksAvailable(networks)
}
chartDataProducer.runTransaction {
updateLook {
it.copy(type = percent.percentChangeType().toChartType())
}
}
}
private suspend fun updateQuotes(newQuotes: TokenQuotes) {
val populatedNewQuotes = currentQuotes.value.populateWith(newQuotes)
quotesStateUpdater.updateQuotes(newQuotes = populatedNewQuotes)
val percent = populatedNewQuotes
.getPercentByInterval(interval = state.value.selectedInterval)
chartDataProducer.runTransaction {
updateLook {
it.copy(type = percent.percentChangeType().toChartType())
}
}
}
private fun onSelectedIntervalChange(interval: PriceChangeInterval) {
if (state.value.selectedInterval == interval) return
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.Chart,
interval = interval,
),
)
// ==================
val quotes = currentQuotes.value
val priceChangePercent = quotes.getFormattedPercentByInterval(interval)
state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy(
priceChangePercentText = priceChangePercent,
selectedInterval = interval,
priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType()
?: PriceChangeType.NEUTRAL,
dateTimeText = getDefaultDateTimeString(interval),
)
}
loadChart(interval)
if (priceChangePercent.isEmpty()) {
loadQuotes()
}
}
private fun onShouldShowPriceSubtitleChange(shouldShow: Boolean) {
state.update { marketsTokenDetailsUM ->
marketsTokenDetailsUM.copy(shouldShowPriceSubtitle = shouldShow)
}
}
@Suppress("MagicNumber")
private fun onMarkerPointSelected(markerTimestamp: BigDecimal?, price: BigDecimal?) {
val currentState = state.value
val dateTimeText = markerTimestamp?.let { bigDecimal ->
MarketsDateTimeFormatters.formatDateByIntervalWithMarker(
interval = currentState.selectedInterval,
markerTimestamp = bigDecimal,
)
} ?: getDefaultDateTimeString(currentState.selectedInterval)
val priceText = (price ?: currentQuotes.value.currentPrice).format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
}
val percent = price?.let { bigDecimal ->
getChangePercentBetween(
previousPrice = bigDecimal,
currentPrice = currentQuotes.value.currentPrice,
)
} ?: currentQuotes.value.getPercentByInterval(currentState.selectedInterval)
val percentText = percent?.format { percent() }.orEmpty()
state.update { stateToUpdate ->
stateToUpdate.copy(
isMarkerSet = markerTimestamp != null,
dateTimeText = dateTimeText,
priceText = priceText,
priceChangePercentText = percentText,
priceChangeType = percent.percentChangeType(),
)
}
chartDataProducer.runTransaction {
updateLook { marketChartLook ->
marketChartLook.copy(
type = percent.percentChangeType().toChartType(),
)
}
}
}
private fun showBottomSheet(content: TangemBottomSheetConfigContent) {
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(
isShown = true,
onDismissRequest = ::hideBottomSheet,
content = content,
),
)
}
}
private fun hideBottomSheet() {
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(isShown = false),
)
}
}
private fun onLoadRetryClicked() {
val currentState = state.value
if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.ERROR) {
loadChart(currentState.selectedInterval)
}
if (currentState.body is MarketsTokenDetailsUM.Body.Error ||
currentState.body is MarketsTokenDetailsUM.Body.Nothing
) {
loadInfo()
modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS)
}
}
private fun onListedOnClick(exchangesCount: Int) {
modelScope.launch {
analyticsEventHandler.send(analyticsEventBuilder.exchangesScreenOpened())
showBottomSheet(content = ExchangesBottomSheetContent.Loading(exchangesCount))
val maybeExchanges = getTokenExchangesUseCase(tokenId = params.token.id)
// Delay to show the bottom sheet
delay(timeMillis = 400L)
updateExchangeBSContent(maybeExchanges = maybeExchanges, exchangesCount = exchangesCount)
}
}
private fun updateExchangeBSContent(
maybeExchanges: Either<Throwable, List<TokenMarketExchange>>,
exchangesCount: Int,
) {
val content = maybeExchanges
.fold(
ifLeft = { _ ->
ExchangesBottomSheetContent.Error(onRetryClick = { onListedOnClick(exchangesCount) })
},
ifRight = { list ->
ExchangesBottomSheetContent.Content(
exchangeItems = ExchangeItemStateConverter.convertList(list).toImmutableList(),
)
},
)
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(content = content),
)
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {
delay(timeMillis)
// Update quotes only when content is visible on the screen
isVisibleOnScreen.first { it }
loadQuotes()
}
}.saveIn(quotesJob)
}
private fun getDefaultDateTimeString(interval: PriceChangeInterval): TextReference {
return MarketsDateTimeFormatters.formatDateByInterval(
interval = interval,
startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval(
interval = interval,
currentTimestamp = lastUpdatedTimestamp.value,
),
)
}
private companion object {
const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.features.feed.model.market.details.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketParams
internal class MarketDetailsAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) {
data class EventBuilder(
val token: TokenMarketParams,
) {
fun screenOpened(blockchain: String?, source: String) = MarketDetailsAnalyticsEvent(
event = "Token Chart Screen Opened",
params = buildMap {
put("Token", token.symbol)
blockchain?.let { put("blockchain", it) }
put("Source", source)
},
)
fun intervalChanged(intervalType: IntervalType, interval: PriceChangeInterval) = MarketDetailsAnalyticsEvent(
event = "Button - Period",
params = mapOf(
"Token" to token.symbol,
"Period" to interval.toAnalyticsString(),
"Source" to intervalType.source,
),
)
fun readMoreClicked() = MarketDetailsAnalyticsEvent(
event = "Button - Read More",
params = mapOf(
"Token" to token.symbol,
),
)
fun linkClicked(linkTitle: String) = MarketDetailsAnalyticsEvent(
event = "Button - Links",
params = mapOf(
"Token" to token.symbol,
"Link" to linkTitle,
),
)
fun exchangesScreenOpened() = MarketDetailsAnalyticsEvent(
event = "Exchanges Screen Opened",
params = mapOf(
"Token" to token.symbol,
),
)
fun securityScoreOpened() = MarketDetailsAnalyticsEvent(
event = "Security Score Info",
params = mapOf("Token" to token.symbol),
)
fun securityScoreProviderClicked(provider: String) = MarketDetailsAnalyticsEvent(
event = "Security Score Provider Clicked",
params = mapOf(
"Token" to token.symbol,
"Provider" to provider,
),
)
}
enum class IntervalType(val source: String) {
Chart("Chart"),
PricePerformance("Price"),
Insights("Insights"),
}
}
private fun PriceChangeInterval.toAnalyticsString() = when (this) {
PriceChangeInterval.H24 -> "24h"
PriceChangeInterval.WEEK -> "7d"
PriceChangeInterval.MONTH -> "1m"
PriceChangeInterval.MONTH3 -> "3m"
PriceChangeInterval.MONTH6 -> "6m"
PriceChangeInterval.YEAR -> "1y"
PriceChangeInterval.ALL_TIME -> "All"
}

View file

@ -0,0 +1,49 @@
package com.tangem.features.feed.model.market.details.converter
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@Suppress("NestedScopeFunctions")
@Stable
internal class DescriptionConverter(
private val onReadModeClicked: (InfoBottomSheetContent) -> Unit,
private val onGeneratedAINotificationClick: () -> Unit,
private val needApplyFCARestrictions: Provider<Boolean>,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.Description?> {
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? {
if (needApplyFCARestrictions()) return null
return value.shortDescription?.let { desc ->
MarketsTokenDetailsUM.Description(
shortDescription = stringReference(desc),
fullDescription = value.fullDescription?.let { fullDescription ->
stringReference(fullDescription)
},
onReadMoreClick = {
onReadModeClicked(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_about_token_title,
wrappedList(
value.name,
),
),
body = stringReference(value.fullDescription.orEmpty()),
generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM(
onClick = onGeneratedAINotificationClick,
),
),
)
},
)
}
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.feed.model.market.details.converter
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.markets.TokenMarketExchange
import com.tangem.domain.markets.TokenMarketExchange.TrustScore
import com.tangem.features.feed.impl.R
import com.tangem.utils.converter.Converter
/**
* Converter from [TokenMarketExchange] to [TokenItemState]
*
[REDACTED_AUTHOR]
*/
internal object ExchangeItemStateConverter : Converter<TokenMarketExchange, TokenItemState> {
override fun convert(value: TokenMarketExchange): TokenItemState {
return TokenItemState.Content(
id = value.id,
iconState = CurrencyIconState.CoinIcon(
url = value.imageUrl,
fallbackResId = R.drawable.ic_alert_24,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = stringReference(value.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(
text = value.volumeInUsd.format {
fiat(
fiatCurrencyCode = "USD",
fiatCurrencySymbol = "$",
).price()
},
),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference(value = if (value.isCentralized) "CEX" else "DEX"),
),
subtitle2State = TokenItemState.Subtitle2State.LabelContent(
auditLabelUM = value.trustScore.toAuditLabelUM(),
),
onItemClick = null,
onItemLongClick = null,
)
}
private fun TrustScore.toAuditLabelUM(): AuditLabelUM {
return when (this) {
TrustScore.Risky -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_risky),
type = AuditLabelUM.Type.Prohibition,
)
TrustScore.Caution -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_caution),
type = AuditLabelUM.Type.Warning,
)
TrustScore.Trusted -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_trusted),
type = AuditLabelUM.Type.Permit,
)
}
}
}

View file

@ -0,0 +1,168 @@
package com.tangem.features.feed.model.market.details.converter
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.rawCompact
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent
import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM
import com.tangem.features.feed.ui.market.detailed.state.InsightsUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@Stable
internal class InsightsConverter(
private val appCurrency: Provider<AppCurrency>,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
private val onIntervalChanged: (PriceChangeInterval) -> Unit,
) : Converter<TokenMarketInfo.Insights, InsightsUM> {
override fun convert(value: TokenMarketInfo.Insights): InsightsUM {
return with(value) {
InsightsUM(
h24Info = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.day,
holdersChange = holdersChange?.day,
liquidityChange = liquidityChange?.day,
buyPressureChange = buyPressureChange?.day,
),
weekInfo = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.week,
holdersChange = holdersChange?.week,
liquidityChange = liquidityChange?.week,
buyPressureChange = buyPressureChange?.week,
),
monthInfo = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.month,
holdersChange = holdersChange?.month,
liquidityChange = liquidityChange?.month,
buyPressureChange = buyPressureChange?.month,
),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_insights),
body = resourceReference(
R.string.markets_insights_info_description_message,
wrappedList(value.sourceNetworks.joinToString { it.name }),
),
),
)
},
onIntervalChanged = onIntervalChanged,
)
}
}
private fun createInfoPointList(
experiencedBuyerChange: BigDecimal?,
holdersChange: BigDecimal?,
liquidityChange: BigDecimal?,
buyPressureChange: BigDecimal?,
): ImmutableList<InfoPointUM> {
return listOfNotNull(
experiencedBuyerChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = experiencedBuyerChange.convertChange(),
change = experiencedBuyerChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_experienced_buyers_full),
body = resourceReference(R.string.markets_token_details_experienced_buyers_description),
),
)
},
)
},
buyPressureChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = buyPressureChange.convertChange(isFiatValue = true),
change = buyPressureChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_buy_pressure_full),
body = resourceReference(R.string.markets_token_details_buy_pressure_description),
),
)
},
)
},
holdersChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = holdersChange.convertChange(),
change = holdersChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_holders_full),
body = resourceReference(R.string.markets_token_details_holders_description),
),
)
},
)
},
liquidityChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = liquidityChange.convertChange(),
change = liquidityChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_liquidity_full),
body = resourceReference(R.string.markets_token_details_liquidity_description),
),
)
},
)
},
).toImmutableList()
}
private fun BigDecimal.changeType(): InfoPointUM.ChangeType? {
return when {
this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP
this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN
else -> null
}
}
private fun BigDecimal.convertChange(isFiatValue: Boolean = false): String {
val value = if (isFiatValue) {
this.abs().format {
val currency = appCurrency()
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).compact()
}
} else {
this.abs().format {
rawCompact()
}
}
return when {
this > BigDecimal.ZERO -> StringsSigns.PLUS + value
this < BigDecimal.ZERO -> StringsSigns.MINUS + value
this == BigDecimal.ZERO -> value
else -> StringsSigns.DASH_SIGN
}
}
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.feed.model.market.details.converter
import androidx.compose.runtime.Stable
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.LinksUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
@Stable
internal class LinksConverter(
private val onLinkClick: (LinksUM.Link) -> Unit,
) : Converter<TokenMarketInfo.Links, LinksUM> {
override fun convert(value: TokenMarketInfo.Links): LinksUM {
return LinksUM(
officialLinks = value.officialLinks?.map { it.convert() }.orEmpty().toImmutableList(),
social = value.social?.map { it.convert() }.orEmpty().toImmutableList(),
repository = value.repository?.map { it.convert() }.orEmpty().toImmutableList(),
blockchainSite = value.blockchainSite?.map { it.convert() }.orEmpty().toImmutableList(),
onLinkClick = onLinkClick,
)
}
private fun TokenMarketInfo.Link.convert(): LinksUM.Link {
return LinksUM.Link(
title = title,
iconRes = getIconById(id),
url = link,
)
}
private fun getIconById(id: String?): Int {
return when (id) {
"linkedin" -> R.drawable.ic_linkedin_24
"discord" -> R.drawable.ic_discord_24
"youtube" -> R.drawable.ic_youtube_24
"telegram" -> R.drawable.ic_telegram_24
"github" -> R.drawable.ic_github_24
"twitter" -> R.drawable.ic_twitter_24
"facebook" -> R.drawable.ic_facebook_24
"reddit" -> R.drawable.ic_reddit_24
"instagram" -> R.drawable.ic_instagram_24
"whitepaper" -> R.drawable.ic_doc_24
else -> R.drawable.ic_arrow_top_right_24
}
}
}

View file

@ -0,0 +1,152 @@
package com.tangem.features.feed.model.market.details.converter
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent
import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM
import com.tangem.features.feed.ui.market.detailed.state.MetricsUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Stable
internal class MetricsConverter(
private val appCurrency: Provider<AppCurrency>,
private val tokenSymbol: String,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
) : Converter<TokenMarketInfo.Metrics, MetricsUM> {
@Suppress("LongMethod")
override fun convert(value: TokenMarketInfo.Metrics): MetricsUM {
return with(value) {
MetricsUM(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = marketCap.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_market_capitalization_full,
),
body = resourceReference(
R.string.markets_token_details_market_capitalization_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_rating),
value = marketRating?.toString() ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_market_rating_full),
body = resourceReference(R.string.markets_token_details_market_rating_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = volume24h.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_trading_volume_full),
body = resourceReference(
R.string.markets_token_details_trading_volume_24h_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = fullyDilutedValuation.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_fully_diluted_valuation_full,
),
body = resourceReference(
R.string.markets_token_details_fully_diluted_valuation_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = circulatingSupply.formatAmount(crypto = true),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_circulating_supply_full),
body = resourceReference(
R.string.markets_token_details_circulating_supply_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_max_supply),
value = maxSupply.formatMaxSupply(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_max_supply_full),
body = resourceReference(R.string.markets_token_details_total_supply_description),
),
)
},
),
),
)
}
}
private fun BigDecimal?.formatMaxSupply(): String {
when (this) {
null -> return StringsSigns.DASH_SIGN
BigDecimal.ZERO -> return StringsSigns.INFINITY_SIGN
}
return this.formatAmount(crypto = true)
}
private fun BigDecimal?.formatAmount(crypto: Boolean = false): String {
if (this == null) return StringsSigns.DASH_SIGN
return if (crypto) {
format {
crypto(
symbol = tokenSymbol,
decimals = 2,
).compact()
}
} else {
val currency = appCurrency()
format {
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).compact()
}
}
}
}

View file

@ -0,0 +1,71 @@
package com.tangem.features.feed.model.market.details.converter
import androidx.compose.runtime.Stable
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.ui.market.detailed.state.PricePerformanceUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import java.math.BigDecimal
import java.math.RoundingMode
@Stable
internal class PricePerformanceConverter(
private val appCurrency: Provider<AppCurrency>,
private val onIntervalChanged: (PriceChangeInterval) -> Unit,
) {
fun convert(value: TokenMarketInfo.PricePerformance, currentPrice: BigDecimal): PricePerformanceUM {
return PricePerformanceUM(
h24 = value.day.convert(currentPrice),
month = value.month.convert(currentPrice),
all = value.allTime.convert(currentPrice),
onIntervalChanged = onIntervalChanged,
)
}
private fun TokenMarketInfo.Range?.convert(currentPrice: BigDecimal): PricePerformanceUM.Value {
if (this == null || this.low == null || this.high == null) {
return PricePerformanceUM.Value(
low = StringsSigns.DASH_SIGN,
high = StringsSigns.DASH_SIGN,
indicatorFraction = 0f,
)
}
return PricePerformanceUM.Value(
low = low.convert(),
high = high.convert(),
indicatorFraction = calculateFraction(currentPrice),
)
}
private fun BigDecimal?.convert(): String {
val currency = appCurrency()
return format {
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).price()
}
}
private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float {
val currentLow = low ?: return 0f
val currentHigh = high ?: return 0f
return when {
high == BigDecimal.ZERO || currentPrice < low -> 0f
currentPrice > high || low == high -> 1f
else -> {
(currentPrice - currentLow).divide(currentHigh - currentLow, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP)
.toFloat().coerceAtMost(1f)
}
}
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.features.feed.model.market.details.converter
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.market.details.formatter.MarketsDateTimeFormatters
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM
import com.tangem.utils.converter.Converter
@Stable
internal class SecurityScoreConverter(
private val onSecurityScoreInfoClick: (SecurityScoreBottomSheetContent) -> Unit,
private val onSecurityScoreProviderLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit,
) : Converter<TokenMarketInfo.SecurityData, SecurityScoreUM> {
override fun convert(value: TokenMarketInfo.SecurityData): SecurityScoreUM {
val ratingsCount = value.securityScoreProviderData.size
return SecurityScoreUM(
score = value.totalSecurityScore,
description = pluralReference(
id = R.plurals.markets_token_details_based_on_ratings,
count = ratingsCount,
formatArgs = wrappedList(ratingsCount),
),
onInfoClick = {
onSecurityScoreInfoClick(
SecurityScoreBottomSheetContent(
title = resourceReference(R.string.markets_token_details_security_score),
description = resourceReference(R.string.markets_token_details_security_score_description),
providers = value.securityScoreProviderData.map { securityScoreProvider ->
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = securityScoreProvider.providerName,
lastAuditDate = securityScoreProvider.lastAuditDate?.let { date ->
MarketsDateTimeFormatters.formatAsDate(date.millis)
},
score = securityScoreProvider.securityScore,
urlData = securityScoreProvider.urlData?.let { urlData ->
SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = urlData.fullUrl,
rootHost = urlData.rootHost,
)
},
iconUrl = securityScoreProvider.iconUrl,
)
},
onProviderLinkClick = onSecurityScoreProviderLinkClick,
),
)
},
)
}
}

View file

@ -0,0 +1,81 @@
package com.tangem.features.feed.model.market.details.converter
import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.ui.market.detailed.state.LinksUM
import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@Stable
@Suppress("LongParameterList")
internal class TokenMarketInfoConverter(
private val appCurrency: Provider<AppCurrency>,
private val needApplyFCARestrictions: Provider<Boolean>,
private val onInfoClick: (TangemBottomSheetConfigContent) -> Unit,
private val onListedOnClick: (Int) -> Unit,
onSecurityScoreInfoClick: (SecurityScoreBottomSheetContent) -> Unit,
onLinkClick: (LinksUM.Link) -> Unit,
onSecurityScoreProviderLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit,
onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
onInsightsIntervalChanged: (PriceChangeInterval) -> Unit,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.InformationBlocks> {
private val insightsConverter = InsightsConverter(
appCurrency = appCurrency,
onInfoClick = onInfoClick,
onIntervalChanged = onInsightsIntervalChanged,
)
private val securityScoreConverter = SecurityScoreConverter(
onSecurityScoreInfoClick = onSecurityScoreInfoClick,
onSecurityScoreProviderLinkClick = onSecurityScoreProviderLinkClick,
)
private val pricePerformanceConverter = PricePerformanceConverter(
appCurrency = appCurrency,
onIntervalChanged = onPricePerformanceIntervalChanged,
)
private val linksConverter = LinksConverter(onLinkClick = onLinkClick)
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks {
val metricsConverter = MetricsConverter(
tokenSymbol = value.symbol,
appCurrency = appCurrency,
onInfoClick = onInfoClick,
)
val exchangesAmount = value.exchangesAmount
val insights = if (needApplyFCARestrictions()) {
null
} else {
value.insights?.let { insightsConverter.convert(it) }
}
val securityScore = if (needApplyFCARestrictions()) {
null
} else {
value.securityData?.let { securityScoreConverter.convert(it) }
}
return MarketsTokenDetailsUM.InformationBlocks(
insights = insights,
securityScore = securityScore,
metrics = value.metrics?.let { metricsConverter.convert(it) },
pricePerformance = value.pricePerformance?.let { pricePerformance ->
pricePerformanceConverter.convert(
value = pricePerformance,
currentPrice = value.quotes.currentPrice,
)
},
listedOn = if (exchangesAmount != null && exchangesAmount > 0) {
ListedOnUM.Content(onClick = { onListedOnClick(exchangesAmount) }, amount = exchangesAmount)
} else {
ListedOnUM.Empty
},
links = value.links?.let { linksConverter.convert(it) },
)
}
}

View file

@ -0,0 +1,76 @@
package com.tangem.features.feed.model.market.details.formatter
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getFiatPriceAmountWithScale
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenQuotes
import java.math.BigDecimal
import java.math.RoundingMode
internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInterval): String {
val percent = when (interval) {
PriceChangeInterval.H24 -> h24ChangePercent
PriceChangeInterval.WEEK -> weekChangePercent
PriceChangeInterval.MONTH -> monthChangePercent
PriceChangeInterval.MONTH3 -> m3ChangePercent
PriceChangeInterval.MONTH6 -> m6ChangePercent
PriceChangeInterval.YEAR -> yearChangePercent
PriceChangeInterval.ALL_TIME -> allTimeChangePercent
}
return percent?.format { percent() }.orEmpty()
}
internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): BigDecimal? {
return when (interval) {
PriceChangeInterval.H24 -> h24ChangePercent
PriceChangeInterval.WEEK -> weekChangePercent
PriceChangeInterval.MONTH -> monthChangePercent
PriceChangeInterval.MONTH3 -> m3ChangePercent
PriceChangeInterval.MONTH6 -> m6ChangePercent
PriceChangeInterval.YEAR -> yearChangePercent
PriceChangeInterval.ALL_TIME -> allTimeChangePercent
}
}
@Suppress("MagicNumber")
internal fun BigDecimal?.percentChangeType(): PriceChangeType {
val scaled = this?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
@Suppress("MagicNumber")
internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: BigDecimal): BigDecimal {
return if (previousPrice == BigDecimal.ZERO) {
BigDecimal.ZERO
} else {
currentPrice.subtract(previousPrice).divide(previousPrice, 4, RoundingMode.HALF_UP)
}
}
internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType {
val current = getFiatPriceAmountWithScale(value = currentPrice).first
val updated = getFiatPriceAmountWithScale(value = updatedPrice).first
return when {
updated > current -> PriceChangeType.UP
updated < current -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
internal fun PriceChangeType.toChartType(): MarketChartLook.Type {
return when (this) {
PriceChangeType.UP -> MarketChartLook.Type.Growing
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral
}
}

View file

@ -0,0 +1,140 @@
package com.tangem.features.feed.model.market.details.formatter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.formatAsDateTime
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.impl.R
import com.tangem.utils.H24_MILLIS
import com.tangem.utils.WEEK_MILLIS
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import java.math.BigDecimal
internal object MarketsDateTimeFormatters {
private val dateTimeMMMFormatter by lazy {
DateTimeFormatters.getBestFormatterBySkeleton("dd MMM Hm")
}
private val dateFormatter = DateTimeFormatters.dateDDMMYYYY
fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String {
return when (interval) {
PriceChangeInterval.H24 -> { value: BigDecimal ->
value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter)
}
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
PriceChangeInterval.MONTH6,
-> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd)
}
PriceChangeInterval.YEAR -> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd)
}
PriceChangeInterval.ALL_TIME -> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateYYYY)
}
}
}
fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference {
return when (interval) {
PriceChangeInterval.H24 -> resourceReference(R.string.common_today)
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
startTimestamp.formatAsDateTime(dateTimeMMMFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
startTimestamp.formatAsDateTime(dateFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.ALL_TIME -> resourceReference(R.string.common_all)
}
}
fun formatDateByIntervalWithMarker(interval: PriceChangeInterval, markerTimestamp: BigDecimal): TextReference {
return when (interval) {
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
markerTimestamp.toLong().formatAsDateTime(dateTimeMMMFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
PriceChangeInterval.ALL_TIME,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
markerTimestamp.toLong().formatAsDateTime(dateFormatter),
),
resourceReference(R.string.common_now),
),
)
}
}
}
@Suppress("MagicNumber")
fun getStartTimestampByInterval(interval: PriceChangeInterval, currentTimestamp: Long): Long {
return when (interval) {
PriceChangeInterval.H24 -> currentTimestamp - H24_MILLIS
PriceChangeInterval.WEEK -> currentTimestamp - WEEK_MILLIS
PriceChangeInterval.MONTH -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(1).millis
PriceChangeInterval.MONTH3 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(3).millis
PriceChangeInterval.MONTH6 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(6).millis
PriceChangeInterval.YEAR -> DateTime(currentTimestamp, DateTimeZone.UTC).minusYears(1).millis
PriceChangeInterval.ALL_TIME -> 0
}
}
fun getDefaultDateTimeString(interval: PriceChangeInterval, currentTimestamp: Long): TextReference {
return formatDateByInterval(
interval = interval,
startTimestamp = getStartTimestampByInterval(
interval = interval,
currentTimestamp = currentTimestamp,
),
)
}
fun formatAsDate(timestamp: Long): String {
return timestamp.formatAsDateTime(dateFormatter)
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.features.feed.model.market.details.state
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenQuotes
import com.tangem.features.feed.model.market.details.converter.PricePerformanceConverter
import com.tangem.features.feed.model.market.details.formatter.*
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.utils.Provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import org.joda.time.DateTime
import java.math.BigDecimal
internal class QuotesStateUpdater(
private val currentAppCurrency: Provider<AppCurrency>,
private val state: MutableStateFlow<MarketsTokenDetailsUM>,
private val currentQuotes: MutableStateFlow<TokenQuotes>,
private val lastUpdatedTimestamp: MutableStateFlow<Long>,
private val currentTokenInfo: MutableStateFlow<TokenMarketInfo?>,
private val onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
) {
private val pricePerformanceConverter = PricePerformanceConverter(
currentAppCurrency,
onIntervalChanged = onPricePerformanceIntervalChanged,
)
suspend fun updateQuotes(newQuotes: TokenQuotes) {
val triggerPriceChangeType = getFormattedPriceChange(
currentPrice = currentQuotes.value.currentPrice,
updatedPrice = newQuotes.currentPrice,
)
val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) {
triggeredEvent(
data = triggerPriceChangeType,
onConsume = {
state.update { it.copy(triggerPriceChange = consumedEvent()) }
},
)
} else {
consumedEvent()
}
val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval)
val priceChangeType = percent.percentChangeType()
// wait until marker is removed
state.first { it.isMarkerSet.not() }
currentQuotes.value = newQuotes
lastUpdatedTimestamp.value = DateTime.now().millis
state.update { stateToUpdate ->
stateToUpdate.copy(
priceText = newQuotes.currentPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency().symbol,
fiatCurrencyCode = currentAppCurrency().code,
).price()
},
priceChangePercentText = newQuotes.getFormattedPercentByInterval(
interval = stateToUpdate.selectedInterval,
),
priceChangeType = priceChangeType,
triggerPriceChange = trigger,
dateTimeText = MarketsDateTimeFormatters.getDefaultDateTimeString(
stateToUpdate.selectedInterval,
currentTimestamp = lastUpdatedTimestamp.value,
),
body = stateToUpdate.body.updatePricePerformance(newQuotes.currentPrice),
)
}
}
private fun MarketsTokenDetailsUM.Body.updatePricePerformance(price: BigDecimal): MarketsTokenDetailsUM.Body {
val currentPricePerformance = currentTokenInfo.value?.pricePerformance ?: return this
return if (this is MarketsTokenDetailsUM.Body.Content) {
copy(
infoBlocks = infoBlocks.copy(
pricePerformance = pricePerformanceConverter.convert(currentPricePerformance, price),
),
)
} else {
this
}
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.feed.model.market.details.state
import com.tangem.domain.markets.TokenMarketInfo
internal sealed class TokenNetworksState {
data object Loading : TokenNetworksState()
data object NoNetworksAvailable : TokenNetworksState()
data class NetworksAvailable(val networks: List<TokenMarketInfo.Network>) : TokenNetworksState()
}

View file

@ -7,6 +7,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.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
@ -19,10 +20,10 @@ import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListCo
import com.tangem.features.feed.model.market.list.analytics.MarketsListAnalyticsEvent
import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager
import com.tangem.features.feed.model.market.list.statemanager.MarketsListUMStateManager
import com.tangem.features.feed.ui.market.list.state.ListUM
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.MarketsNotificationUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.ListUM
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.MarketsNotificationUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -105,6 +106,7 @@ internal class MarketsListModel @Inject constructor(
private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager
val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED)
val isVisibleOnScreen = MutableStateFlow(false)
val state = marketsListUMStateManager.state.asStateFlow()
@ -289,6 +291,12 @@ internal class MarketsListModel @Inject constructor(
}
private fun initAnalytics() {
containerBottomSheetState.onEach { bottomSheetState ->
if (bottomSheetState == BottomSheetState.EXPANDED) {
analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened())
}
}.launchIn(modelScope)
state.filter { it.isInSearchMode.not() }
.map { MarketsListAnalyticsEvent.SortBy(it.selectedSortBy, it.selectedInterval) }.distinctUntilChanged()
.onEach {
@ -308,6 +316,9 @@ internal class MarketsListModel @Inject constructor(
launch {
while (true) {
delay(timeMillis)
// Update quotes only when the container bottom sheet is in the expanded state
containerBottomSheetState.first { it == BottomSheetState.EXPANDED }
// and is visible on the screen
isVisibleOnScreen.first { it }
activeListManager.updateQuotes()
}

View file

@ -1,14 +1,16 @@
package com.tangem.features.feed.model.market.list.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
internal sealed class MarketsListAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets", event = event, params = params) {
class BottomSheetOpened : MarketsListAnalyticsEvent(event = "Markets Screen Opened")
data class SortBy(
val sortByTypeUM: SortByTypeUM,
val interval: MarketsListUM.TrendInterval,

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.market.list.state
package com.tangem.features.feed.model.market.list.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.market.list.state
package com.tangem.features.feed.model.market.list.state
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.market.list.state
package com.tangem.features.feed.model.market.list.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent

View file

@ -8,8 +8,8 @@ import com.tangem.features.feed.model.converter.MarketsTokenItemConverter
import com.tangem.features.feed.model.market.list.utils.logAction
import com.tangem.features.feed.model.market.list.utils.logStatus
import com.tangem.features.feed.model.market.list.utils.logUpdateResults
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult

View file

@ -9,7 +9,12 @@ import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.list.state.*
import com.tangem.features.feed.model.market.list.state.ListUM
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.MarketsNotificationUM
import com.tangem.features.feed.model.market.list.state.MarketsSearchBar
import com.tangem.features.feed.model.market.list.state.SortByBottomSheetContentUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList

View file

@ -1,56 +0,0 @@
package com.tangem.features.feed.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.features.feed.components.FeedEntryChildFactory
@Composable
internal fun EntryBottomSheetContent(
stackState: ChildStack<FeedEntryChildFactory.Child, ComposableModularContentComponent>,
onHeaderSizeChange: (Dp) -> Unit,
) {
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
Scaffold(
containerColor = background,
contentWindowInsets = WindowInsets(0.dp),
topBar = {
AnimatedContent(
targetState = stackState.active.instance,
modifier = Modifier.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
onHeaderSizeChange(coordinates.size.height.toDp())
}
}
},
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentState ->
currentState.Title()
}
},
content = { contentPadding ->
AnimatedContent(
targetState = stackState.active.instance,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentState ->
currentState.Content(modifier = Modifier.padding(contentPadding))
}
},
)
}

View file

@ -0,0 +1,75 @@
package com.tangem.features.feed.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.fade
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.feed.components.FeedEntryChildFactory
@Composable
internal fun EntryContent(
bottomSheetState: State<BottomSheetState>,
stackState: State<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>>,
onHeaderSizeChange: (Dp) -> Unit,
isOpenedInBottomSheet: Boolean,
) {
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
Surface(contentColor = background) {
Scaffold(
containerColor = background,
contentWindowInsets = WindowInsetsZero,
topBar = {
AnimatedContent(
targetState = stackState.value.active.instance,
modifier = Modifier
.then(
if (!isOpenedInBottomSheet) {
Modifier.statusBarsPadding()
} else {
Modifier
},
)
.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
onHeaderSizeChange(coordinates.size.height.toDp())
}
}
},
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { currentState -> currentState.Title(bottomSheetState) }
},
content = { contentPadding ->
Children(
stack = stackState.value,
animation = stackAnimation(fade()),
) { child ->
child.instance.Content(
modifier = Modifier.padding(contentPadding),
bottomSheetState = bottomSheetState,
)
}
},
)
}
}

View file

@ -51,7 +51,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
@Composable
internal fun FeedListHeader(feedListSearchBar: FeedListSearchBar, modifier: Modifier = Modifier) {

View file

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

View file

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

View file

@ -5,8 +5,8 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.model.converter.MarketsTokenItemConverter
import com.tangem.features.feed.ui.market.list.state.MarketsListUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult

View file

@ -0,0 +1,330 @@
package com.tangem.features.feed.ui.market.detailed
import android.content.res.Configuration
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
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.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
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.distinctUntilChanged
@Suppress("LongParameterList")
@Composable
internal fun MarketsTokenDetailsContent(
state: MarketsTokenDetailsUM,
backgroundColor: Color,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
Content(
modifier = modifier,
backgroundColor = backgroundColor,
state = state,
portfolioBlock = portfolioBlock,
isAccountEnabled = isAccountEnabled,
)
when (state.bottomSheetConfig.content) {
is InfoBottomSheetContent -> InfoBottomSheet(config = state.bottomSheetConfig)
is SecurityScoreBottomSheetContent -> SecurityScoreBottomSheet(config = state.bottomSheetConfig)
is ExchangesBottomSheetContent -> ExchangesBottomSheet(config = state.bottomSheetConfig)
}
}
@Suppress("LongParameterList")
@Composable
private fun Content(
state: MarketsTokenDetailsUM,
backgroundColor: Color,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
val lazyListState = rememberLazyListState()
ShowPriceSubtitleEffect(
lazyListState = lazyListState,
onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange,
)
Column(
modifier = modifier
.drawBehind { drawRect(backgroundColor) }
.fillMaxSize(),
) {
SpacerH4()
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight),
) {
item("header") {
Header(
state = state,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
item { SpacerH16() }
item("intervalSelector") {
IntervalSelector(
trendInterval = state.selectedInterval,
onIntervalClick = state.onSelectedIntervalChange,
isEnabled = state.body !is MarketsTokenDetailsUM.Body.Nothing,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
item { SpacerH32() }
item("chart") {
MarketTokenDetailsChart(
modifier = Modifier.fillMaxWidth(),
backgroundColor = backgroundColor,
state = state.chartState,
)
}
item { SpacerH16() }
tokenMarketDetailsBody(
state = state.body,
isAccountEnabled = isAccountEnabled,
portfolioBlock = portfolioBlock,
)
}
}
}
@Suppress("LongParameterList")
@Composable
internal fun MarketsTokenDetailsTopBar(
backgroundColor: Color,
shouldShowPriceSubtitle: Boolean,
tokenName: String,
tokenPrice: String,
isBackButtonEnabled: Boolean,
onBackClick: () -> Unit,
) {
TangemTopAppBar(
modifier = Modifier.drawBehind { drawRect(backgroundColor) },
title = tokenName,
subtitle = if (shouldShowPriceSubtitle) tokenPrice else null,
startButton = TopAppBarButtonUM.Back(
onBackClicked = onBackClick,
enabled = isBackButtonEnabled,
),
)
}
@Composable
private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
TokenPriceText(
price = state.priceText,
triggerPriceChange = state.triggerPriceChange,
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
Text(
text = state.dateTimeText.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
if (state.priceChangePercentText != null) {
PriceChangeInPercent(
valueInPercent = state.priceChangePercentText,
type = state.priceChangeType,
textStyle = TangemTheme.typography.caption2,
)
}
}
}
SpacerW4()
CoinIcon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size48),
url = state.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
}
}
@Composable
private fun TokenPriceText(
price: String,
triggerPriceChange: StateEvent<PriceChangeType>,
modifier: Modifier = Modifier,
) {
val growColor = TangemTheme.colors.text.accent
val fallColor = TangemTheme.colors.text.warning
val generalColor = TangemTheme.colors.text.primary1
val color = remember(generalColor) { Animatable(generalColor) }
EventEffect(triggerPriceChange) { priceChangeType ->
val nextColor = when (priceChangeType) {
PriceChangeType.UP,
-> growColor
PriceChangeType.DOWN -> fallColor
PriceChangeType.NEUTRAL -> return@EventEffect
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
Text(
text = price,
modifier = modifier,
color = color.value,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.head.fontSize),
maxLines = 1,
style = TangemTheme.typography.head,
)
}
@Composable
private fun IntervalSelector(
trendInterval: PriceChangeInterval,
isEnabled: Boolean,
onIntervalClick: (PriceChangeInterval) -> Unit,
modifier: Modifier = Modifier,
) {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
PriceChangeInterval.ALL_TIME,
),
color = TangemTheme.colors.button.secondary,
initialSelectedItem = trendInterval,
onClick = onIntervalClick,
isEnabled = isEnabled,
modifier = modifier,
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = if (isEnabled) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.disabled
},
)
}
}
}
@Composable
private fun ShowPriceSubtitleEffect(lazyListState: LazyListState, onShouldShowPriceSubtitleChange: (Boolean) -> Unit) {
val showPriceSubtitleFlow = remember(lazyListState) {
snapshotFlow { lazyListState.firstVisibleItemIndex > 1 }
.distinctUntilChanged()
}
LaunchedEffect(showPriceSubtitleFlow) {
showPriceSubtitleFlow.collect { isVisible ->
onShouldShowPriceSubtitleChange(isVisible)
}
}
}
@Composable
fun PriceChangeInterval.getText(): TextReference {
return when (this) {
PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title)
PriceChangeInterval.WEEK -> resourceReference(R.string.markets_selector_interval_7d_title)
PriceChangeInterval.MONTH -> resourceReference(R.string.markets_selector_interval_1m_title)
PriceChangeInterval.MONTH3 -> resourceReference(R.string.markets_selector_interval_3m_title)
PriceChangeInterval.MONTH6 -> resourceReference(R.string.markets_selector_interval_6m_title)
PriceChangeInterval.YEAR -> resourceReference(R.string.markets_selector_interval_1y_title)
PriceChangeInterval.ALL_TIME -> resourceReference(R.string.markets_selector_interval_all_title)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun MarketsTokenDetailsContent_Preview(
@PreviewParameter(MarketsTokenDetailsContentPreviewProvider::class) params: MarketsTokenDetailsUM,
) {
TangemThemePreview {
MarketsTokenDetailsContent(
state = params,
backgroundColor = TangemTheme.colors.background.tertiary,
portfolioBlock = {},
isAccountEnabled = true,
)
}
}
private class MarketsTokenDetailsContentPreviewProvider : PreviewParameterProvider<MarketsTokenDetailsUM> {
override val values: Sequence<MarketsTokenDetailsUM>
get() = sequenceOf(
MarketsTokenDetailsPreview.loadingState,
MarketsTokenDetailsPreview.contentState,
)
}
// endregion

View file

@ -0,0 +1,204 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.annotation.StringRes
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.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
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.market.detailed.state.ExchangesBottomSheetContent
import kotlinx.collections.immutable.toImmutableList
/**
* Exchanges bottom sheet
*
* @param config bottom sheet config
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun ExchangesBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<ExchangesBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { Title(textResId = it.titleResId, onBackClick = config.onDismissRequest) },
content = { content ->
Box {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomBarHeight),
) {
item(key = "subtitle") {
Subtitle(
subtitleRes = content.subtitleResId,
volumeReference = content.volumeReference,
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing8,
),
)
}
items(
items = content.exchangeItems,
key = TokenItemState::id,
itemContent = { TokenItem(state = it, isBalanceHidden = false) },
)
}
if (content is ExchangesBottomSheetContent.Error) {
Error(
content = content,
modifier = Modifier.align(Alignment.Center),
)
}
}
},
)
}
@Composable
private fun Title(@StringRes textResId: Int, onBackClick: () -> Unit) {
TangemTopAppBar(
title = stringResourceSafe(id = textResId),
startButton = TopAppBarButtonUM.Back(onBackClicked = onBackClick),
)
}
@Composable
private fun Subtitle(@StringRes subtitleRes: Int, volumeReference: TextReference, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
SubtitleText(textReference = resourceReference(id = subtitleRes))
SubtitleText(textReference = volumeReference)
}
}
@Composable
private fun SubtitleText(textReference: TextReference) {
Text(
text = textReference.resolveReference(),
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun Error(content: ExchangesBottomSheetContent.Error, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(id = content.message),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption1,
)
SpacerH12()
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(id = R.string.alert_button_try_again),
onClick = content.onRetryClick,
),
)
}
}
@Preview
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ExchangesBottomSheet(
@PreviewParameter(ExchangesBottomSheetContentProvider::class) content: ExchangesBottomSheetContent,
) {
TangemThemePreview {
ExchangesBottomSheet(
config = TangemBottomSheetConfig(
onDismissRequest = {},
content = content,
isShown = true,
),
)
}
}
private class ExchangesBottomSheetContentProvider : CollectionPreviewParameterProvider<ExchangesBottomSheetContent>(
listOf(
ExchangesBottomSheetContent.Loading(exchangesCount = 13),
ExchangesBottomSheetContent.Error(onRetryClick = {}),
ExchangesBottomSheetContent.Content(
exchangeItems = List(size = 13) { index ->
TokenItemState.Content(
id = index.toString(),
iconState = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.ic_facebook_24,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "OKX")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "$67.52M"),
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference(value = "CEX")),
subtitle2State = TokenItemState.Subtitle2State.LabelContent(
auditLabelUM = AuditLabelUM(
text = stringReference("Caution"),
type = AuditLabelUM.Type.Warning,
),
),
onItemClick = {},
onItemLongClick = {},
)
}
.toImmutableList(),
),
),
)

View file

@ -0,0 +1,79 @@
package com.tangem.features.feed.ui.market.detailed.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
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.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent
import dev.jeziellago.compose.markdowntext.MarkdownText
@Composable
internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<InfoBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { TangemBottomSheetTitle(title = it.title) },
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
MarkdownText(
markdown = content.body.resolveReference(),
disableLinkMovementMethod = true,
linkifyMask = 0,
syntaxHighlightColor = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2.copy(
color = TangemTheme.colors.text.secondary,
),
)
if (content.generatedAINotificationUM != null) {
AdditionalInfoNotification(
onClick = content.generatedAINotificationUM.onClick,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
SpacerH(bottomBarHeight)
}
},
)
}
@Composable
private fun AdditionalInfoNotification(onClick: () -> Unit, modifier: Modifier = Modifier) {
Notification(
config = NotificationConfig(
subtitle = TextReference.Res(id = R.string.information_generated_with_ai),
iconResId = R.drawable.ic_magic_28,
onClick = onClick,
shouldShowArrowIcon = false,
),
modifier = modifier,
subtitleColor = TangemTheme.colors.text.primary1,
containerColor = TangemTheme.colors.button.disabled,
iconTint = TangemTheme.colors.icon.accent,
)
}

View file

@ -0,0 +1,193 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.compose.foundation.background
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.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
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.R
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM
@Composable
internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
if (infoPointUM.onInfoClick != null) {
TooltipText(
text = infoPointUM.title,
onInfoClick = infoPointUM.onInfoClick,
textStyle = TangemTheme.typography.caption2,
)
} else {
Text(
text = infoPointUM.title.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row {
Text(
text = infoPointUM.value,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
if (infoPointUM.change != null) {
SpacerW4()
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size8)
.align(Alignment.CenterVertically),
imageVector = ImageVector.vectorResource(
id = when (infoPointUM.change) {
InfoPointUM.ChangeType.UP -> R.drawable.ic_arrow_up_8
InfoPointUM.ChangeType.DOWN -> R.drawable.ic_arrow_down_8
},
),
tint = when (infoPointUM.change) {
InfoPointUM.ChangeType.UP -> TangemTheme.colors.icon.accent
InfoPointUM.ChangeType.DOWN -> TangemTheme.colors.icon.warning
},
contentDescription = null,
)
}
}
}
}
@Composable
internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolean = false) {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
if (withTooltip) {
Box(
modifier = Modifier
.requiredHeight(TangemTheme.dimens.size16)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.caption2,
textSizeHeight = false,
)
}
} else {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.caption2,
textSizeHeight = true,
)
}
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
style = TangemTheme.typography.body1,
textSizeHeight = true,
)
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
Column(
modifier = Modifier
.width(150.dp)
.background(TangemTheme.colors.background.tertiary),
) {
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000,000",
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000,000",
onInfoClick = { },
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000",
change = InfoPointUM.ChangeType.UP,
onInfoClick = { },
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000",
change = InfoPointUM.ChangeType.DOWN,
onInfoClick = { },
),
)
}
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewShimmer() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
Column(
modifier = Modifier
.width(150.dp)
.background(TangemTheme.colors.background.tertiary),
) {
InfoPointShimmer(modifier = Modifier.fillMaxWidth())
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
}
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -0,0 +1,215 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.block.information.GridItems
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.getText
import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM
import com.tangem.features.feed.ui.market.detailed.state.InsightsUM
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) {
var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) }
InformationBlock(
modifier = modifier,
title = {
TooltipText(
text = resourceReference(R.string.markets_token_details_insights),
textStyle = TangemTheme.typography.subtitle2,
onInfoClick = state.onInfoClick,
)
},
action = {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
),
initialSelectedItem = PriceChangeInterval.H24,
onClick = { interval ->
currentInterval = interval
state.onIntervalChanged(interval)
},
modifier = Modifier.width(IntrinsicSize.Min),
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
horizontal = 14.dp,
vertical = 4.dp,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
},
content = {
val infoPoints = when (currentInterval) {
PriceChangeInterval.H24 -> state.h24Info
PriceChangeInterval.WEEK -> state.weekInfo
PriceChangeInterval.MONTH -> state.monthInfo
else -> state.h24Info
}
GridItems(
items = infoPoints,
itemContent = { infoPointUM ->
InfoPoint(
modifier = Modifier.align(Alignment.CenterStart),
infoPointUM = infoPointUM,
)
},
)
},
)
}
@Composable
internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) {
val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() }
val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() }
val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4
InformationBlock(
modifier = modifier,
title = {
RectangleShimmer(
modifier = Modifier
.height(headerHeight)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
},
content = {
GridItems(
items = List(size = 4) { it }.toImmutableList(),
horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
itemContent = {
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
)
},
)
},
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
InsightsBlock(
state = InsightsUM(
h24Info = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000 000 000",
),
),
weekInfo = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000 000",
),
),
monthInfo = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000",
),
),
onInfoClick = {},
onIntervalChanged = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = { ContentPreview() },
shimmerContent = { InsightsBlockPlaceholder() },
)
}
}

View file

@ -0,0 +1,226 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.ChipShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.chip.Chip
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
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.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.LinksUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
contentHorizontalPadding = 0.dp,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_links),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
content = {
Column {
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_official_links),
links = state.officialLinks,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_social),
links = state.social,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_repository),
links = state.repository,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_blockchain_site),
links = state.blockchainSite,
onLinkClick = state.onLinkClick,
lastBlock = true,
)
}
},
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SubBlock(
links: ImmutableList<LinksUM.Link>,
onLinkClick: (LinksUM.Link) -> Unit,
modifier: Modifier = Modifier,
lastBlock: Boolean = false,
title: String = "Official links",
) {
if (links.isEmpty()) return
DividerContainer(
modifier = modifier,
showDivider = !lastBlock,
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
text = title,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
links.fastForEach { link ->
Chip(
text = stringReference(link.title),
iconResId = link.iconRes,
onClick = { onLinkClick(link) },
)
}
}
}
}
}
@Composable
fun LinksBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
contentHorizontalPadding = 0.dp,
title = {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.subtitle2,
)
},
content = {
Column {
SubBlockPlaceholder()
SubBlockPlaceholder()
SubBlockPlaceholder(lastBlock = true)
}
},
)
}
@Composable
private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolean = false) {
DividerContainer(
modifier = modifier,
showDivider = !lastBlock,
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
TextShimmer(
modifier = Modifier.width(78.dp),
style = TangemTheme.typography.caption2,
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
repeat(times = 3) {
ChipShimmer(
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
LinksBlock(
state = LinksUM(
officialLinks = persistentListOf(
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
social = persistentListOf(
LinksUM.Link(
title = "Twitter",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Facebook",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
repository = persistentListOf(
LinksUM.Link(
title = "Github",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
blockchainSite = persistentListOf(),
onLinkClick = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaceholderPreview() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = { LinksBlockPlaceholder() },
actualContent = { ContentPreview() },
)
}
}

View file

@ -0,0 +1,143 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.R
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM
import kotlinx.coroutines.delay
/**
* "Listed on" block
*
* @param state block state
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun ListedOnBlock(state: ListedOnUM, modifier: Modifier = Modifier) {
Box(modifier = modifier) {
InformationBlock(
title = {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = Modifier
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
},
) {
Description(
state = state,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
)
}
if (state is ListedOnUM.Content) {
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = TangemTheme.dimens.spacing12),
)
}
}
}
@Composable
internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
title = {
TextShimmer(
style = TangemTheme.typography.subtitle2,
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
)
},
modifier = modifier,
) {
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.fillMaxWidth(fraction = 0.3f)
.padding(bottom = TangemTheme.dimens.spacing12),
)
}
}
@Composable
private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) {
Text(
text = state.description.resolveReference(),
modifier = modifier,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Preview(widthDp = 328, heightDp = 68)
@Preview(name = "Dark Theme", widthDp = 328, heightDp = 68, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ListedOnBlock(@PreviewParameter(ListenOnUMProvider::class) state: ListedOnUM?) {
TangemThemePreview {
if (state == null) {
ListedOnBlockPlaceholder()
} else {
ListedOnBlock(state = state)
}
}
}
@Preview
@Composable
private fun Preview_ListedOnBlock_StateChanging() {
var state by remember { mutableStateOf<ListedOnUM?>(value = null) }
Preview_ListedOnBlock(state = state)
LaunchedEffect(key1 = null) {
delay(timeMillis = 3000)
state = ListedOnUM.Empty
}
}
private class ListenOnUMProvider : CollectionPreviewParameterProvider<ListedOnUM?>(
collection = listOf(
ListedOnUM.Empty,
ListedOnUM.Content(onClick = {}, amount = 5),
null,
),
)

View file

@ -0,0 +1,84 @@
package com.tangem.features.feed.ui.market.detailed.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
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.graphics.Color
import com.tangem.common.ui.charts.MarketChart
import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.rememberMarketChartState
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
@Composable
internal fun MarketTokenDetailsChart(
state: MarketsTokenDetailsUM.ChartState,
backgroundColor: Color,
modifier: Modifier = Modifier,
) {
val growingColor = TangemTheme.colors.icon.accent
val fallingColor = TangemTheme.colors.icon.warning
val neutralColor = TangemTheme.colors.icon.informative
val chartState = rememberMarketChartState(
dataProducer = state.dataProducer,
colorMapper = { type ->
when (type) {
MarketChartLook.Type.Growing -> growingColor
MarketChartLook.Type.Falling -> fallingColor
MarketChartLook.Type.Neutral -> neutralColor
}
},
onMarkerShown = state.onMarkerPointSelected,
)
val bottomChartAxisHeight = getMarketChartBottomAxisHeight()
Box(modifier) {
MarketChart(
modifier = Modifier.fillMaxWidth(),
state = chartState,
)
if (state.status != MarketsTokenDetailsUM.ChartState.Status.DATA) {
Box(
Modifier
.drawBehind { drawRect(backgroundColor) }
.matchParentSize()
.padding(bottom = bottomChartAxisHeight),
) {
when (state.status) {
MarketsTokenDetailsUM.ChartState.Status.LOADING -> {
CircularProgressIndicator(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.align(Alignment.Center),
color = TangemTheme.colors.text.accent,
strokeWidth = TangemTheme.dimens.size2,
)
}
MarketsTokenDetailsUM.ChartState.Status.ERROR -> {
UnableToLoadData(
modifier = Modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
)
.align(Alignment.Center),
onRetryClick = state.onLoadRetryClick,
)
}
else -> {}
}
}
}
}
}

View file

@ -0,0 +1,168 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.TextButton
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.GridItems
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM
import com.tangem.features.feed.ui.market.detailed.state.MetricsUM
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
const val MAX_METRICS_COUNT = 6
@Composable
internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) {
var isExpanded by remember { mutableStateOf(false) }
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_metrics),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
if (state.metrics.size > MAX_METRICS_COUNT) {
ShowLessMoreButton(expanded = isExpanded, onClick = { isExpanded = !isExpanded })
}
},
content = {
val metrics = if (isExpanded) {
state.metrics
} else {
state.metrics.take(MAX_METRICS_COUNT).toImmutableList()
}
GridItems(
items = metrics,
itemContent = {
InfoPoint(infoPointUM = it)
},
)
},
)
}
// TODO make TextButton clickable area smaller and remove paddings for an action in InformationBlock
@Composable
private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) {
// FIXME add string resources
val text = if (expanded) {
"See less"
} else {
"See more"
}
TextButton(
text = text,
onClick = onClick,
colors = TangemButtonsDefaults.positiveButtonColors,
textStyle = TangemTheme.typography.body2,
)
}
@Composable
internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
title = {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.subtitle2,
)
},
action = {
Box(Modifier)
},
content = {
GridItems(
items = List(size = 6) { it }.toImmutableList(),
horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
itemContent = {
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
},
)
},
)
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BlockPreview() {
TangemThemePreview {
MetricsBlock(
state = MetricsUM(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_rating),
value = "A",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_total_supply),
value = "1.2T",
onInfoClick = {},
),
),
),
)
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = { BlockPreview() },
shimmerContent = { MetricsBlockPlaceholder() },
)
}
}

View file

@ -0,0 +1,269 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
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.RectangleShimmer
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.progressbar.TangemLinearProgressIndicator
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemAnimations
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.getText
import com.tangem.features.feed.ui.market.detailed.state.PricePerformanceUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier = Modifier) {
var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) }
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_price_performance),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.MONTH,
PriceChangeInterval.ALL_TIME,
),
initialSelectedItem = PriceChangeInterval.H24,
onClick = { interval ->
currentInterval = interval
state.onIntervalChanged(interval)
},
modifier = Modifier.width(IntrinsicSize.Min),
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
horizontal = 14.dp,
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
},
content = {
val value = when (currentInterval) {
PriceChangeInterval.H24 -> state.h24
PriceChangeInterval.MONTH -> state.month
PriceChangeInterval.ALL_TIME -> state.all
else -> error("")
}
Content(
modifier = Modifier.fillMaxWidth(),
state = value,
)
},
)
}
@Composable
private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifier) {
val animatedIndicatorFraction by TangemAnimations.horizontalIndicatorAsState(
targetFraction = state.indicatorFraction,
)
Column(
modifier = modifier
.padding(vertical = TangemTheme.dimens.spacing8),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResourceSafe(R.string.markets_token_details_low),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
SpacerW8()
Text(
text = stringResourceSafe(R.string.markets_token_details_high),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
TangemLinearProgressIndicator(
modifier = Modifier
.height(TangemTheme.dimens.size6)
.fillMaxWidth(),
progress = { animatedIndicatorFraction },
color = TangemTheme.colors.text.accent,
backgroundColor = TangemTheme.colors.background.tertiary,
strokeCap = StrokeCap.Round,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
Text(
modifier = Modifier.weight(1f),
text = state.low,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.weight(1f),
text = state.high,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.End,
)
}
}
}
@Composable
internal fun PricePerformanceBlockPlaceholder(modifier: Modifier = Modifier) {
val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() }
val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() }
val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4
InformationBlock(
modifier = modifier,
title = {
RectangleShimmer(
modifier = Modifier
.height(headerHeight)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
},
content = {
Column(
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing8),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.width(35.dp),
style = TangemTheme.typography.caption2,
)
SpacerW8()
TextShimmer(
modifier = Modifier.width(35.dp),
style = TangemTheme.typography.caption2,
)
}
RectangleShimmer(
modifier = Modifier
.height(TangemTheme.dimens.size6)
.fillMaxWidth(),
radius = 27.dp,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size56),
style = TangemTheme.typography.body1,
)
SpacerW8()
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size56),
style = TangemTheme.typography.body1,
)
}
}
},
)
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
PricePerformanceBlock(
modifier = Modifier,
state = PricePerformanceUM(
h24 = PricePerformanceUM.Value(
low = "\$38,5K",
high = "\$58,5K",
indicatorFraction = 0.5f,
),
month = PricePerformanceUM.Value(
low = "\$500,5K",
high = "\$5800,5K",
indicatorFraction = 0.8f,
),
all = PricePerformanceUM.Value(
low = "\$58,52",
high = "\$580,5M",
indicatorFraction = 0.2f,
),
onIntervalChanged = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaceholderPreview() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
PricePerformanceBlockPlaceholder()
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -0,0 +1,100 @@
package com.tangem.features.feed.ui.market.detailed.components
import androidx.annotation.FloatRange
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
import kotlin.math.round
private const val STARS_COUNT = 5
@Composable
internal fun ScoreStarsBlock(
score: Float,
horizontalSpacing: Dp,
scoreTextStyle: TextStyle,
modifier: Modifier = Modifier,
) {
val rounded = score.roundTo1decimal()
val percentage = rounded / STARS_COUNT
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(horizontalSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = rounded.toString(),
style = scoreTextStyle,
color = TangemTheme.colors.text.primary1,
)
Stars(fraction = percentage)
}
}
@Suppress("MagicNumber")
@Composable
private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) {
val grayColor = TangemTheme.colors.icon.inactive
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(times = 5) { i ->
Box(
modifier = Modifier.size(TangemTheme.dimens.size16),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier
.requiredSize(16.dp)
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithCache {
onDrawWithContent {
val starFraction = ((fraction - i * 0.2) / 0.2).coerceIn(0.0, 1.0)
val starFractionFloat = starFraction
.toFloat()
.roundTo1decimal()
drawContent()
drawRect(
color = grayColor,
topLeft = Offset(x = size.width * starFractionFloat, y = 0f),
size = Size(size.width * (1 - starFractionFloat), size.height),
blendMode = BlendMode.SrcIn,
)
}
},
imageVector = ImageVector.vectorResource(R.drawable.ic_star_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
}
}
@Suppress("MagicNumber")
private fun Float.roundTo1decimal(): Float {
return round(this * 10) / 10
}

View file

@ -0,0 +1,140 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.text.TooltipText
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM
@Composable
internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.fillMaxWidth()
.heightIn(max = TangemTheme.dimens.size72)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1F)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
TooltipText(
text = resourceReference(R.string.markets_token_details_security_score),
onInfoClick = state.onInfoClick,
textStyle = TangemTheme.typography.subtitle2,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ScoreStarsBlock(
score = state.score,
scoreTextStyle = TangemTheme.typography.body1,
horizontalSpacing = TangemTheme.dimens.spacing8,
)
}
}
@Composable
internal fun SecurityScoreBlockPlaceholder(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary)
.fillMaxWidth()
.heightIn(max = TangemTheme.dimens.size72)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier
.fillMaxWidth(fraction = 0.4f)
.padding(vertical = TangemTheme.dimens.spacing2)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.subtitle2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
}
@Preview(widthDp = 328, showBackground = true)
@Preview(widthDp = 328, showBackground = true, locale = "ru")
@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
SecurityScoreBlock(
state = SecurityScoreUM(
score = 3.5f,
description = stringReference("Based on 3 ratings"),
onInfoClick = {},
),
)
}
}
@Preview(widthDp = 328, showBackground = true)
@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
SecurityScoreBlockPlaceholder(
modifier = Modifier.fillMaxWidth(),
)
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -0,0 +1,190 @@
package com.tangem.features.feed.ui.market.detailed.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
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.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEachIndexed
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.preview.SecurityScorePreviewData
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent
@Composable
internal fun SecurityScoreBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<SecurityScoreBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { TangemBottomSheetTitle(title = it.title) },
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
Text(
text = content.description.resolveReference(),
style = TangemTheme.typography.body2.copy(
color = TangemTheme.colors.text.secondary,
),
)
SpacerH12()
content.providers.fastForEachIndexed { index, provider ->
DividerContainer(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = content.providers.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action),
showDivider = index != content.providers.lastIndex,
) {
SecurityScoreProviderRow(
providerUM = provider,
onLinkClick = { content.onProviderLinkClick(provider) },
)
}
}
SpacerH16()
SpacerH(bottomBarHeight)
}
},
)
}
@Composable
private fun SecurityScoreProviderRow(
providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM,
onLinkClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing12)
.heightIn(min = TangemTheme.dimens.size68),
verticalAlignment = Alignment.CenterVertically,
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(providerUM.iconUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
error = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Column(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
Text(
text = providerUM.name,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
providerUM.lastAuditDate?.let { auditDate ->
Text(
text = auditDate,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
SpacerWMax()
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
modifier = Modifier.clickable(
enabled = providerUM.urlData != null,
indication = ripple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
onClick = onLinkClick,
),
) {
ScoreStarsBlock(
score = providerUM.score,
scoreTextStyle = TangemTheme.typography.body2,
horizontalSpacing = TangemTheme.dimens.spacing3,
)
UrlBlock(providerUM)
}
}
}
@Composable
private fun UrlBlock(providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM) {
val urlData = providerUM.urlData
val rootHost = urlData?.rootHost
if (urlData != null && rootHost != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
Text(
text = urlData.rootHost,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_arrow_top_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SecurityScoreBottomSheetPreview() {
TangemThemePreview {
SecurityScoreBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = SecurityScorePreviewData.bottomSheetContent,
),
)
}
}

View file

@ -0,0 +1,200 @@
package com.tangem.features.feed.ui.market.detailed.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.items.DescriptionItem
import com.tangem.core.ui.components.items.DescriptionPlaceholder
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
@Suppress("CanBeNonNullable") // TODO will be removed after [REDACTED_JIRA]
internal fun LazyListScope.tokenMarketDetailsBody(
state: MarketsTokenDetailsUM.Body,
isAccountEnabled: Boolean,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
when (state) {
MarketsTokenDetailsUM.Body.Loading -> {
item("description-loading") {
DescriptionPlaceholder(modifier = Modifier.blockPaddings())
}
if (portfolioBlock != null) {
item(key = "portfolio") {
portfolioBlock(Modifier.blockPaddings())
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
loadingInfoBlocks()
}
is MarketsTokenDetailsUM.Body.Content -> {
if (state.description != null) {
description(state.description)
}
if (portfolioBlock != null) {
item(key = "portfolio") {
portfolioBlock(Modifier.blockPaddings())
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
infoBlocksList(state.infoBlocks)
}
is MarketsTokenDetailsUM.Body.Error -> {
error(state)
}
MarketsTokenDetailsUM.Body.Nothing -> {
// Do nothing
}
}
}
private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) {
item("body-error") {
Box(Modifier.fillMaxWidth()) {
UnableToLoadData(
modifier = Modifier
.align(Alignment.Center)
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing40,
),
onRetryClick = state.onLoadRetryClick,
)
}
}
}
private fun LazyListScope.aboutCoinHeader() {
item("aboutCoinHeader") {
Text(
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing20,
),
text = stringResourceSafe(R.string.markets_about_coin_header),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h3,
)
}
}
private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) {
item("description") {
DescriptionItem(
modifier = Modifier.blockPaddings(),
description = description.shortDescription,
hasFullDescription = description.fullDescription != null,
onReadMoreClick = description.onReadMoreClick,
)
}
}
internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks) {
if (state.insights != null) {
item("insights") {
InsightsBlock(
modifier = Modifier.blockPaddings(),
state = state.insights,
)
}
}
if (state.securityScore != null) {
item("securityScore") {
SecurityScoreBlock(
modifier = Modifier.blockPaddings(),
state = state.securityScore,
)
}
}
if (state.metrics != null) {
item("metrics") {
MetricsBlock(
modifier = Modifier.blockPaddings(),
state = state.metrics,
)
}
}
if (state.pricePerformance != null) {
item("pricePerformance") {
PricePerformanceBlock(
modifier = Modifier.blockPaddings(),
state = state.pricePerformance,
)
}
}
item(key = "listedOn") {
ListedOnBlock(
state = state.listedOn,
modifier = Modifier.blockPaddings(),
)
}
if (state.links != null) {
item("links") {
LinksBlock(
modifier = Modifier.blockPaddings(),
state = state.links,
)
}
}
}
private fun LazyListScope.loadingInfoBlocks() {
item("insights-loading") {
InsightsBlockPlaceholder(
modifier = Modifier.blockPaddings(),
)
}
item("securityScore-loading") {
SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("metrics-loading") {
MetricsBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("pricePerformance-loading") {
PricePerformanceBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item(key = "listedOn-loading") {
ListedOnBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("links-loading") {
LinksBlockPlaceholder(modifier = Modifier.blockPaddings())
}
}
@Composable
private fun Modifier.blockPaddings(): Modifier {
return this.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing12,
)
}

View file

@ -0,0 +1,139 @@
package com.tangem.features.feed.ui.market.detailed.preview
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM
import com.tangem.features.feed.ui.market.detailed.state.InsightsUM
import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.features.feed.ui.market.detailed.state.MetricsUM
import com.tangem.features.feed.ui.market.detailed.state.PricePerformanceUM
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM
import kotlinx.collections.immutable.persistentListOf
internal object MarketsTokenDetailsPreview {
private val infoPoint = InfoPointUM(
title = stringReference("1"),
value = "2",
change = InfoPointUM.ChangeType.DOWN,
onInfoClick = {},
)
val loadingState = MarketsTokenDetailsUM(
tokenName = "Token Name",
priceText = "$0.00000000324",
dateTimeText = stringReference("Today"),
priceChangePercentText = "52.00%",
iconUrl = "",
priceChangeType = PriceChangeType.UP,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = MarketChartDataProducer.build { },
onLoadRetryClick = {},
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = { _, _ -> },
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = { },
body = MarketsTokenDetailsUM.Body.Loading,
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
isMarkerSet = false,
triggerPriceChange = consumedEvent(),
onShouldShowPriceSubtitleChange = {},
shouldShowPriceSubtitle = false,
)
val contentState = MarketsTokenDetailsUM(
tokenName = "Token Name",
priceText = "$0.00000000324",
dateTimeText = stringReference("Today"),
priceChangePercentText = "52.00%",
iconUrl = "",
priceChangeType = PriceChangeType.UP,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = MarketChartDataProducer.build { },
onLoadRetryClick = {},
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = { _, _ -> },
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = { },
body = MarketsTokenDetailsUM.Body.Content(
description = MarketsTokenDetailsUM.Description(
shortDescription = stringReference("markets_token_details_description_short"),
fullDescription = stringReference("markets_token_details_description_full"),
onReadMoreClick = {},
),
infoBlocks = MarketsTokenDetailsUM.InformationBlocks(
insights = InsightsUM(
h24Info = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
weekInfo = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
monthInfo = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
onInfoClick = {},
onIntervalChanged = {},
),
securityScore = SecurityScoreUM(
score = 2.3f,
description = stringReference("markets_token_details_security_score_description"),
onInfoClick = {},
),
metrics = MetricsUM(
metrics = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
),
pricePerformance = PricePerformanceUM(
h24 = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
month = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
all = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
onIntervalChanged = {},
),
listedOn = ListedOnUM.Empty,
links = null,
),
),
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
isMarkerSet = true,
triggerPriceChange = consumedEvent(),
onShouldShowPriceSubtitleChange = {},
shouldShowPriceSubtitle = false,
)
}

View file

@ -0,0 +1,60 @@
package com.tangem.features.feed.ui.market.detailed.preview
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent
internal object SecurityScorePreviewData {
val bottomSheetContent = SecurityScoreBottomSheetContent(
title = stringReference("Security score"),
description = stringReference(
"Security score of a token is a metric that assesses the " +
"security level of a blockchain or token based on various factors and is compiled from " +
"the sources listed below.",
),
providers = listOf(
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Moralis",
lastAuditDate = "21.10.2024",
score = 4.9F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://moralis.com/",
rootHost = "moralis.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Certik",
lastAuditDate = "10.07.2024",
score = 4.6F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://certik.com/",
rootHost = "certik.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Cyberscope",
lastAuditDate = "25.06.2023",
score = 4.5F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://cyberscope.com/",
rootHost = "cyberscope.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "TokenInsight",
lastAuditDate = "17.01.2022",
score = 4.0F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://tokeninsight.com/",
rootHost = "tokeninsight.com",
),
iconUrl = "",
),
),
onProviderLinkClick = {},
)
}

View file

@ -0,0 +1,72 @@
package com.tangem.features.feed.ui.market.detailed.state
import androidx.annotation.StringRes
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.plus
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.feed.impl.R
import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Exchanges bottom sheet content
*
[REDACTED_AUTHOR]
*/
internal sealed interface ExchangesBottomSheetContent : TangemBottomSheetConfigContent {
/** Title of bottom sheet. Like, app bar. */
@get:StringRes
val titleResId: Int
get() = R.string.markets_token_details_exchanges_title
/** Subtitle */
@get:StringRes
val subtitleResId: Int
get() = R.string.markets_token_details_exchange
/** Volume info */
val volumeReference: TextReference
get() = resourceReference(id = R.string.markets_token_details_volume) +
stringReference(value = " $DOT ") +
resourceReference(id = R.string.markets_selector_interval_24h_title)
/** Exchange items */
val exchangeItems: ImmutableList<TokenItemState>
/**
* Loading state
*
* @property exchangesCount count of exchanges
*/
data class Loading(val exchangesCount: Int) : ExchangesBottomSheetContent {
override val exchangeItems: ImmutableList<TokenItemState>
get() = List(size = exchangesCount) { index -> TokenItemState.Loading(id = "loading#$index") }
.toImmutableList()
}
/**
* Content state
*
* @property exchangeItems exchanges
*/
data class Content(
override val exchangeItems: ImmutableList<TokenItemState>,
) : ExchangesBottomSheetContent
/** Error state */
data class Error(
val onRetryClick: () -> Unit,
) : ExchangesBottomSheetContent {
override val exchangeItems: ImmutableList<TokenItemState> = persistentListOf()
@StringRes
val message: Int = R.string.markets_loading_error_title
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.feed.ui.market.detailed.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
internal data class InfoBottomSheetContent(
val title: TextReference,
val body: TextReference,
val generatedAINotificationUM: GeneratedAINotificationUM? = null,
) : TangemBottomSheetConfigContent {
data class GeneratedAINotificationUM(val onClick: () -> Unit)
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.feed.ui.market.detailed.state
import com.tangem.core.ui.extensions.TextReference
internal data class InfoPointUM(
val title: TextReference,
val value: String,
val change: ChangeType? = null,
val onInfoClick: (() -> Unit)? = null,
) {
enum class ChangeType {
UP, DOWN
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.feed.ui.market.detailed.state
import com.tangem.domain.markets.PriceChangeInterval
import kotlinx.collections.immutable.ImmutableList
internal data class InsightsUM(
val h24Info: ImmutableList<InfoPointUM>,
val weekInfo: ImmutableList<InfoPointUM>,
val monthInfo: ImmutableList<InfoPointUM>,
val onInfoClick: () -> Unit,
val onIntervalChanged: (PriceChangeInterval) -> Unit,
)

View file

@ -0,0 +1,18 @@
package com.tangem.features.feed.ui.market.detailed.state
import androidx.annotation.DrawableRes
import kotlinx.collections.immutable.ImmutableList
internal data class LinksUM(
val officialLinks: ImmutableList<Link>,
val social: ImmutableList<Link>,
val repository: ImmutableList<Link>,
val blockchainSite: ImmutableList<Link>,
val onLinkClick: (Link) -> Unit,
) {
data class Link(
@DrawableRes val iconRes: Int,
val title: String,
val url: String,
)
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.feed.ui.market.detailed.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.feed.impl.R
/**
* "Listed on" block UI model
*
[REDACTED_AUTHOR]
*/
internal sealed interface ListedOnUM {
/** Title */
val title: TextReference
get() = resourceReference(id = R.string.markets_token_details_listed_on)
/** Description */
val description: TextReference
/** Empty state. No exchanges found */
data object Empty : ListedOnUM {
override val description = resourceReference(id = R.string.markets_token_details_empty_exchanges)
}
/**
* Content with number of exchanges
*
* @property onClick lambda be invoked when button is clicked
* @property amount amount of exchanges
*/
data class Content(
val onClick: () -> Unit,
private val amount: Int,
) : ListedOnUM {
override val description: TextReference = pluralReference(
id = R.plurals.markets_token_details_amount_exchanges,
count = amount,
formatArgs = wrappedList(amount),
)
}
}

View file

@ -0,0 +1,72 @@
package com.tangem.features.feed.ui.market.detailed.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.markets.PriceChangeInterval
import java.math.BigDecimal
internal data class MarketsTokenDetailsUM(
val tokenName: String,
val priceText: String,
val iconUrl: String?,
val dateTimeText: TextReference,
val priceChangePercentText: String?,
val priceChangeType: PriceChangeType,
val selectedInterval: PriceChangeInterval,
val isMarkerSet: Boolean,
val chartState: ChartState,
val onSelectedIntervalChange: (PriceChangeInterval) -> Unit,
val bottomSheetConfig: TangemBottomSheetConfig,
val triggerPriceChange: StateEvent<PriceChangeType>,
val body: Body,
val shouldShowPriceSubtitle: Boolean,
val onShouldShowPriceSubtitleChange: (Boolean) -> Unit,
) {
data class ChartState(
val status: Status,
val dataProducer: MarketChartDataProducer,
val onLoadRetryClick: () -> Unit,
val onMarkerPointSelected: (time: BigDecimal?, price: BigDecimal?) -> Unit,
) {
enum class Status {
LOADING, ERROR, DATA
}
}
data class InformationBlocks(
val insights: InsightsUM?,
val securityScore: SecurityScoreUM?,
val metrics: MetricsUM?,
val pricePerformance: PricePerformanceUM?,
val listedOn: ListedOnUM,
val links: LinksUM?,
)
@Immutable
sealed interface Body {
data class Error(
val onLoadRetryClick: () -> Unit,
) : Body
data object Loading : Body
data class Content(
val description: Description?,
val infoBlocks: InformationBlocks,
) : Body
data object Nothing : Body
}
data class Description(
val shortDescription: TextReference,
val fullDescription: TextReference?,
val onReadMoreClick: () -> Unit,
)
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.feed.ui.market.detailed.state
import kotlinx.collections.immutable.ImmutableList
internal data class MetricsUM(
val metrics: ImmutableList<InfoPointUM>,
)

View file

@ -0,0 +1,17 @@
package com.tangem.features.feed.ui.market.detailed.state
import androidx.annotation.FloatRange
import com.tangem.domain.markets.PriceChangeInterval
internal data class PricePerformanceUM(
val h24: Value,
val month: Value,
val all: Value,
val onIntervalChanged: (PriceChangeInterval) -> Unit,
) {
data class Value(
val low: String,
val high: String,
@FloatRange(from = 0.0, to = 1.0) val indicatorFraction: Float,
)
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.feed.ui.market.detailed.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
internal data class SecurityScoreBottomSheetContent(
val title: TextReference,
val description: TextReference,
val providers: List<SecurityScoreProviderUM>,
val onProviderLinkClick: (SecurityScoreProviderUM) -> Unit,
) : TangemBottomSheetConfigContent {
data class SecurityScoreProviderUM(
val name: String,
val lastAuditDate: String?,
val score: Float,
val urlData: UrlData?,
val iconUrl: String?,
) {
data class UrlData(
val fullUrl: String,
val rootHost: String?,
)
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.feed.ui.market.detailed.state
import androidx.annotation.FloatRange
import com.tangem.core.ui.extensions.TextReference
internal data class SecurityScoreUM(
@FloatRange(from = 0.0, to = 5.0) val score: Float,
val description: TextReference,
val onInfoClick: () -> Unit,
)

View file

@ -38,10 +38,10 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.market.list.state.*
import com.tangem.features.feed.ui.market.list.components.MarketsListLazyColumn
import com.tangem.features.feed.ui.market.list.components.MarketsListSortByBottomSheet
import com.tangem.features.feed.ui.market.list.components.YieldSupplyInMarketsPromoNotification
import com.tangem.features.feed.ui.market.list.state.*
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@ -49,7 +49,12 @@ import kotlinx.coroutines.delay
private const val SHOW_MORE_KEY = "privacyPolicy"
@Composable
internal fun TopBarWithSearch(onBackClick: () -> Unit, onSearchClick: () -> Unit, marketsSearchBar: MarketsSearchBar) {
internal fun TopBarWithSearch(
buttonsEnabled: Boolean,
onBackClick: () -> Unit,
onSearchClick: () -> Unit,
marketsSearchBar: MarketsSearchBar,
) {
val background = LocalMainBottomSheetColor.current.value
val focusRequester: FocusRequester = remember { FocusRequester() }
@ -59,6 +64,8 @@ internal fun TopBarWithSearch(onBackClick: () -> Unit, onSearchClick: () -> Unit
if (showAppBarWithBackIcon) {
AppBarWithBackButtonAndIcon(
onBackClick = onBackClick,
backButtonEnabled = buttonsEnabled,
endButtonEnabled = buttonsEnabled,
text = stringResourceSafe(R.string.markets_common_title),
iconRes = R.drawable.ic_search_24,
onIconClick = onSearchClick,

View file

@ -25,7 +25,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.list.state.ListUM
import com.tangem.features.feed.model.market.list.state.ListUM
import kotlinx.coroutines.launch
private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50

View file

@ -18,8 +18,8 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.list.state.SortByBottomSheetContentUM
import com.tangem.features.feed.ui.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.SortByBottomSheetContentUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
@Composable
fun MarketsListSortByBottomSheet(config: TangemBottomSheetConfig) {

View file

@ -54,7 +54,10 @@ internal class WalletComponent @AssistedInject constructor(
private val model: WalletModel = getOrCreateModel()
private val feedEntryComponent by lazy {
feedEntryComponentFactory.create(child("feedEntryComponent"))
feedEntryComponentFactory.create(
context = child("feedEntryComponent"),
entryRoute = null,
)
}
private val marketsEntryComponent by lazy {
marketsEntryComponentFactory.create(child("marketsEntryComponent"))