Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-30 11:46:31 +03:00
commit 14a7ac4f5e
431 changed files with 23077 additions and 3701 deletions

View file

@ -13,7 +13,6 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.ToastMessage
import com.tangem.core.ui.utils.showErrorDialog
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.status.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.GetArchivedAccountsUseCase
@ -125,11 +124,8 @@ internal class ArchivedAccountListModel @Inject constructor(
messageSender.send(
DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),
message = resourceReference(
id = R.string.account_recover_limit_dialog_description,
formatArgs = wrappedList(AccountList.MAX_ACCOUNTS_COUNT.toString()),
),
title = resourceReference(R.string.account_recover_limit_dialog_title),
message = resourceReference(R.string.account_archived_recover_error_message),
firstActionBuilder = { firstAction },
),
)
@ -139,7 +135,19 @@ internal class ArchivedAccountListModel @Inject constructor(
val featureError = AccountFeatureError.ArchivedAccountList.FailedToRecoverAccount(cause = error)
logError(error = featureError)
messageSender.showErrorDialog(universalError = featureError, onDismiss = router::pop)
val messageText = resourceReference(
id = R.string.universal_error,
formatArgs = wrappedList(featureError.errorCode),
)
val message = DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),
message = messageText,
firstAction = EventMessageAction(
title = resourceReference(R.string.common_ok),
onClick = {},
),
)
messageSender.send(message)
}
private fun logError(error: AccountFeatureError, params: Map<String, String> = emptyMap()) {

View file

@ -314,7 +314,7 @@ internal class AccountCreateEditModel @Inject constructor(
private fun showAccountNameExist() {
val dialogMessage = DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),
title = resourceReference(R.string.account_form_name_already_exist_error_title),
message = resourceReference(R.string.account_form_name_already_exist_error_description),
)
messageSender.send(dialogMessage)

View file

@ -120,15 +120,21 @@ internal class AccountDetailsModel @Inject constructor(
error = error.tag,
)
analyticsEventHandler.send(event)
val titleRes = R.string.common_something_went_wrong
val messageRes = when (error) {
val titleRes: Int
val messageRes: Int
when (error) {
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountListRequirementsNotMet,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountNotFound,
is ArchiveCryptoPortfolioUseCase.Error.CriticalTechError.AccountsNotCreated,
is ArchiveCryptoPortfolioUseCase.Error.DataOperationFailed,
-> R.string.account_generic_error_dialog_message
is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus,
-> R.string.account_could_not_archive_referral_program_message
-> {
titleRes = R.string.common_something_went_wrong
messageRes = R.string.account_generic_error_dialog_message
}
is ArchiveCryptoPortfolioUseCase.Error.ActiveReferralStatus -> {
titleRes = R.string.account_could_not_archive_referral_program_title
messageRes = R.string.account_could_not_archive_referral_program_message
}
}
val dialogMessage = DialogMessage(

View file

@ -23,10 +23,7 @@ import com.tangem.features.wallet.utils.UserWalletsFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -64,13 +61,18 @@ internal class UserWalletListModel @Inject constructor(
)
init {
combine(
flow = userWalletsFetcher.userWallets,
flow2 = shouldSaveUserWalletsUseCase(),
flow3 = isWalletSavingInProgress,
) { userWallets, shouldSaveUserWallets, isWalletSavingInProgress ->
updateState(userWallets, shouldSaveUserWallets, isWalletSavingInProgress)
}.launchIn(modelScope)
modelScope.launch {
val userWalletsFlow = userWalletsFetcher.userWallets.stateIn(this)
state.update { value -> value.copy(userWallets = userWalletsFlow.value) }
combine(
flow = userWalletsFlow,
flow2 = shouldSaveUserWalletsUseCase(),
flow3 = isWalletSavingInProgress,
) { userWallets, shouldSaveUserWallets, isWalletSavingInProgress ->
updateState(userWallets, shouldSaveUserWallets, isWalletSavingInProgress)
}.collect()
}
}
private fun updateState(

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,30 @@
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
@Serializable
data class NewsDetail(val articleId: Int, val preselectedArticlesId: List<Int>) : FeedEntryRoute
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.entry.deeplink
import android.net.Uri
import kotlinx.coroutines.CoroutineScope
interface NewsDetailsDeepLinkHandler {
interface Factory {
fun create(coroutineScope: CoroutineScope, deeplinkUri: Uri): NewsDetailsDeepLinkHandler
}
}

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,30 +1,32 @@
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.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.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
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.components.news.details.DefaultNewsDetailsComponent
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.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
@ -32,6 +34,7 @@ import dagger.assisted.AssistedInject
@Stable
internal class DefaultFeedEntryComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted entryRoute: FeedEntryRoute?,
private val feedEntryChildFactory: FeedEntryChildFactory,
) : FeedEntryComponent, AppComponentContext by context {
@ -54,17 +57,39 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
blockchain = null,
source = "Market",
),
onBackClicked = { onChildBack() },
onArticleClick = { articleId, preselectedArticlesId ->
onArticleClick(articleId, preselectedArticlesId)
},
),
),
)
}
override fun onMarketOpenClick(sortBy: SortByTypeUM) {
innerRouter.push(FeedEntryChildFactory.Child.TokenList)
override fun onMarketOpenClick(sortBy: SortByTypeUM?) {
innerRouter.push(
route = FeedEntryChildFactory.Child.TokenList(
params = DefaultMarketsTokenListComponent.Params(
onBackClicked = { onChildBack() },
onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
preselectedSortType = sortBy ?: SortByTypeUM.Rating,
shouldAlwaysShowSearchBar = sortBy == null,
),
),
)
}
override fun onArticleClick(articleId: Int) {
innerRouter.push(FeedEntryChildFactory.Child.NewsDetails)
override fun onArticleClick(articleId: Int, preselectedArticlesId: List<Int>) {
innerRouter.push(
FeedEntryChildFactory.Child.NewsDetails(
params = DefaultNewsDetailsComponent.Params(
articleId = articleId,
onBackClicked = { onChildBack() },
preselectedArticlesId = preselectedArticlesId,
onTokenClick = { token, currency -> onMarketItemClick(token, currency) },
),
),
)
}
override fun onOpenAllNews() {
@ -72,23 +97,25 @@ 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,
onBackClicked = { onChildBack() },
)
},
)
@Composable
override fun BottomSheetContent(
@ -96,27 +123,89 @@ 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 background = TangemTheme.colors.background.tertiary
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
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() },
onArticleClick = { articleId, preselectedArticlesId ->
clickIntents.onArticleClick(articleId, preselectedArticlesId)
},
),
)
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
DefaultMarketsTokenListComponent.Params(
onBackClicked = { router.pop() },
onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) },
preselectedSortType = SortByTypeUM.Rating,
shouldAlwaysShowSearchBar = false,
),
)
is FeedEntryRoute.NewsDetail -> FeedEntryChildFactory.Child.NewsDetails(
DefaultNewsDetailsComponent.Params(
articleId = entryRoute.articleId,
onBackClicked = { router.pop() },
preselectedArticlesId = entryRoute.preselectedArticlesId,
onTokenClick = { token, currency -> clickIntents.onMarketItemClick(token, currency) },
),
)
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,18 +1,25 @@
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.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
internal class FeedEntryChildFactory @Inject constructor() {
internal class FeedEntryChildFactory @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
) {
@Serializable
@Immutable
@ -24,7 +31,7 @@ internal class FeedEntryChildFactory @Inject constructor() {
@Serializable
@Immutable
data object TokenList : Child
data class TokenList(val params: DefaultMarketsTokenListComponent.Params) : Child
@Serializable
@Immutable
@ -36,40 +43,49 @@ internal class FeedEntryChildFactory @Inject constructor() {
@Serializable
@Immutable
data object NewsDetails : Child
data class NewsDetails(val params: DefaultNewsDetailsComponent.Params) : Child
}
fun createChild(
child: Child,
appComponentContext: AppComponentContext,
feedEntryClickIntents: FeedEntryClickIntents,
): ComposableModularContentComponent {
onBackClicked: () -> Unit,
): ComposableModularBottomSheetContentComponent {
return when (child) {
is Child.TokenDetails -> {
DefaultMarketsTokenDetailsComponent(
appComponentContext = appComponentContext,
params = child.params,
analyticsEventHandler = analyticsEventHandler,
accountsFeatureToggles = accountsFeatureToggles,
portfolioComponentFactory = portfolioComponentFactory,
)
}
is Child.TokenList -> {
DefaultMarketsTokenListComponent(
appComponentContext = appComponentContext,
onTokenClick = { token, appCurrency ->
feedEntryClickIntents.onMarketItemClick(
token,
appCurrency,
)
},
params = child.params,
)
}
Child.NewsDetails -> {
is Child.NewsDetails -> {
DefaultNewsDetailsComponent(
appComponentContext = appComponentContext,
params = child.params,
)
}
Child.NewsList -> {
DefaultNewsListComponent(
appComponentContext = appComponentContext,
params = DefaultNewsListComponent.Params(
onArticleClicked = { currentArticle, prefetchedArticles ->
feedEntryClickIntents.onArticleClick(
articleId = currentArticle,
preselectedArticlesId = prefetchedArticles,
)
},
onBackClick = onBackClicked,
),
)
}
Child.Feed -> {

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.searchBar)
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,127 @@
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.context.child
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.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.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
internal class DefaultMarketsTokenDetailsComponent(
appComponentContext: AppComponentContext,
val params: Params,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
portfolioComponentFactory: MarketsPortfolioComponent.Factory,
) : 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)
private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.shouldShowPortfolio) {
portfolioComponentFactory.create(
context = child("my_portfolio"),
params = MarketsPortfolioComponent.Params(
updatedParams.token,
analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) },
),
)
} else {
null
}
init {
componentScope.launch(dispatchers.default) {
model.networksState.collectLatest { state ->
when (state) {
is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(state.networks)
TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable()
else -> {}
}
}
}
// === 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()
val background = LocalMainBottomSheetColor.current.value
MarketsTokenDetailsTopBar(
onBackClick = { params.onBackClicked() },
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
shouldShowPriceSubtitle = state.shouldShowPriceSubtitle,
tokenName = state.tokenName,
tokenPrice = state.priceText,
backgroundColor = background,
)
}
@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 = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
}
},
)
}
@Serializable
@ -31,6 +130,8 @@ internal class DefaultMarketsTokenDetailsComponent(
val appCurrency: AppCurrency,
val shouldShowPortfolio: Boolean,
val analyticsParams: AnalyticsParams?,
val onBackClicked: () -> Unit,
val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit,
)
@Serializable

View file

@ -0,0 +1,18 @@
package com.tangem.features.feed.components.market.details.portfolio.add.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
internal interface AddToPortfolioComponent : ComposableBottomSheetComponent {
data class Params(
val addToPortfolioManager: AddToPortfolioManager,
val callback: Callback,
)
interface Callback {
fun onDismiss()
}
interface Factory : ComponentFactory<Params, AddToPortfolioComponent>
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.feed.components.market.details.portfolio.add.api
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
internal interface AddToPortfolioManager {
val token: TokenMarketParams
val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?
val portfolioFetcher: PortfolioFetcher
val state: StateFlow<State>
val allAvailableNetworks: Flow<List<TokenMarketInfo.Network>>
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>)
sealed interface State {
data object Init : State
data class AvailableToAdd(
val availableToAddData: AvailableToAddData,
) : State
data object NothingToAdd : State
}
interface Factory {
fun create(
scope: CoroutineScope,
token: TokenMarketParams,
analyticsParams: MarketsPortfolioComponent.AnalyticsParams?,
): AddToPortfolioManager
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.feed.components.market.details.portfolio.add.api
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
internal data class AvailableToAddData(
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet>,
) {
val isAvailableToAdd: Boolean
get() = availableToAddWallets.isNotEmpty()
val isSinglePortfolio: Boolean
get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1
}
internal data class AvailableToAddWallet(
val userWallet: UserWallet,
val accounts: List<AccountStatus>,
val availableNetworks: Set<TokenMarketInfo.Network>,
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount>,
)
@Serializable
internal data class AvailableToAddAccount(
val account: AccountStatus,
val availableNetworks: Set<TokenMarketInfo.Network>,
val addedNetworks: Set<Network>,
) {
val isSingleNetwork: Boolean
get() = availableNetworks.size == 1
val availableToAddNetworks: Set<TokenMarketInfo.Network> = availableNetworks
.filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } }
.toSet()
val addedMarketNetworks: Set<TokenMarketInfo.Network> = availableNetworks
.filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } }
.toSet()
}
@Serializable
internal data class SelectedPortfolio(
val userWallet: UserWallet,
val account: AvailableToAddAccount,
val isAccountMode: Boolean,
val isAvailableMorePortfolio: Boolean,
)
internal data class SelectedNetwork(
val selectedNetwork: TokenMarketInfo.Network,
val cryptoCurrency: CryptoCurrency,
val isAvailableMoreNetwork: Boolean,
)

View file

@ -0,0 +1,55 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.addtoken.AddTokenContent
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork
import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class AddTokenComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableContentComponent {
private val model: AddTokenModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val um = state.value ?: return
AddTokenContent(
modifier = modifier,
state = um,
)
}
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val selectedPortfolio: Flow<SelectedPortfolio>,
val selectedNetwork: Flow<SelectedNetwork>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onChangeNetworkClick()
fun onChangePortfolioClick()
fun onTokenAdded(status: CryptoCurrencyStatus)
}
@AssistedFactory
interface Factory : ComponentFactory<Params, AddTokenComponent> {
override fun create(context: AppComponentContext, params: Params): AddTokenComponent
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.ChooseNetworkContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class ChooseNetworkComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableContentComponent {
private val model: ChooseNetworkModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ChooseNetworkContent(state)
}
data class Params(
val selectedPortfolio: SelectedPortfolio,
val callbacks: Callbacks,
)
interface Callbacks {
fun onNetworkSelected(network: TokenMarketInfo.Network)
}
@AssistedFactory
interface Factory : ComponentFactory<Params, ChooseNetworkComponent> {
override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent
}
}

View file

@ -0,0 +1,212 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.backStack
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes
import com.tangem.features.feed.impl.R
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddToPortfolioComponent.Params,
portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
addTokenComponentFactory: AddTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory,
) : AppComponentContext by context, AddToPortfolioComponent {
private val model: AddToPortfolioModel = getOrCreateModel(params)
private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create(
context = child("portfolioSelectorComponent"),
params = PortfolioSelectorComponent.Params(
portfolioFetcher = model.portfolioFetcher,
controller = model.portfolioSelectorController,
),
)
private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create(
context = child("addTokenComponent"),
params = AddTokenComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
selectedPortfolio = model.selectedPortfolio,
selectedNetwork = model.selectedNetwork,
),
)
private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create(
context = child("tokenActionsComponent"),
params = TokenActionsComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
data = model.tokenActionsData,
),
)
private val childStack = childStack(
key = "addToPortfolioStack",
handleBackButton = true,
source = model.navigation,
serializer = AddToPortfolioRoutes.serializer(),
initialStack = { model.currentStack },
childFactory = ::contentChild,
)
private fun onBack() {
if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss()
}
override fun dismiss() {
params.callback.onDismiss()
}
@Composable
override fun BottomSheet() {
val stack by childStack.subscribeAsState()
val contentStack = remember { mutableStateOf(stack) }
val currentRoute = stack.active.configuration
val isNotEmpty = currentRoute != AddToPortfolioRoutes.Empty
if (isNotEmpty) {
contentStack.value = stack
}
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
scrollableContent = false,
onBack = ::onBack,
config = TangemBottomSheetConfig(
isShown = isNotEmpty,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.tertiary,
title = { state ->
AnimatedContent(targetState = contentStack.value) { stack ->
BottomSheetTitle(
stack = stack,
onBackClick = ::onBack,
modifier = Modifier.fillMaxWidth(),
)
}
},
content = { state ->
AnimatedContent(targetState = contentStack.value) { stack ->
val paddingModifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
)
val isScrollableContent = when (stack.active.configuration) {
AddToPortfolioRoutes.PortfolioSelector -> false
AddToPortfolioRoutes.AddToken,
AddToPortfolioRoutes.Empty,
is AddToPortfolioRoutes.NetworkSelector,
AddToPortfolioRoutes.TokenActions,
-> true
}
if (isScrollableContent) {
Column(
modifier = paddingModifier.verticalScroll(rememberScrollState()),
) {
stack.active.instance.Content(modifier = Modifier)
}
} else {
stack.active.instance.Content(modifier = paddingModifier)
}
}
},
)
}
@Composable
private fun BottomSheetTitle(
stack: ChildStack<AddToPortfolioRoutes, ComposableContentComponent>,
onBackClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val title: TextReference = when (stack.active.configuration) {
AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token)
AddToPortfolioRoutes.Empty -> TextReference.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network)
AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token)
AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent)
.title.collectAsStateWithLifecycle().value
}
val startIconRes: Int?
val endIconRes: Int?
if (stack.backStack.isNotEmpty()) {
startIconRes = R.drawable.ic_back_24
endIconRes = null
} else {
startIconRes = null
endIconRes = R.drawable.ic_close_24
}
TangemModalBottomSheetTitle(
modifier = modifier,
title = title,
startIconRes = startIconRes,
endIconRes = endIconRes,
onStartClick = onBackClick,
onEndClick = onBackClick,
)
}
private fun contentChild(
config: AddToPortfolioRoutes,
componentContext: ComponentContext,
): ComposableContentComponent = when (config) {
AddToPortfolioRoutes.AddToken -> addTokenComponent
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create(
context = childByContext(componentContext),
params = ChooseNetworkComponent.Params(
selectedPortfolio = config.selectedPortfolio,
callbacks = model,
),
)
}
@AssistedFactory
interface Factory : AddToPortfolioComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddToPortfolioComponent.Params,
): DefaultAddToPortfolioComponent
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.TokenActionsContent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class TokenActionsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : AppComponentContext by context, ComposableContentComponent {
private val model: TokenActionsModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TokenReceiveConfig.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val tokenActionsUM = state.value ?: return
TokenActionsContent(
modifier = modifier,
state = tokenActionsUM,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: TokenReceiveConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val data: Flow<PortfolioData.CryptoCurrencyData>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onLaterClick()
}
@AssistedFactory
interface Factory : ComponentFactory<Params, TokenActionsComponent> {
override fun create(context: AppComponentContext, params: Params): TokenActionsComponent
}
}

View file

@ -0,0 +1,119 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.converter
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddAccount
import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddData
import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddWallet
import javax.inject.Inject
internal class AvailableToAddDataConverter @Inject constructor(
private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) {
suspend fun convert(
balances: Map<UserWalletId, PortfolioFetcher.PortfolioBalance>,
availableNetworks: Set<TokenMarketInfo.Network>,
marketParams: TokenMarketParams,
): AvailableToAddData {
suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? {
val currencies = availableNetworks
.mapNotNull { network ->
createCryptoCurrency(
userWallet = wallet,
network = network,
marketParams = marketParams,
account = this.account,
)
}
if (currencies.isEmpty()) return null
val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies)
.fold(
ifEmpty = { emptySet() },
ifSome = { map ->
map.values.flatMapTo(hashSetOf()) { statuses ->
statuses.map { it.currency.network }
}
},
)
return AvailableToAddAccount(
account = this,
availableNetworks = availableNetworks,
addedNetworks = addedNetworks,
)
}
suspend fun getAvailableToAddWallet(
entry: Map.Entry<UserWalletId, PortfolioFetcher.PortfolioBalance>,
): AvailableToAddWallet {
val (_, balance) = entry
val wallet = balance.userWallet
val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks)
val accounts = balance.accountsBalance.accountStatuses
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount> = accounts
.mapNotNull { accountStatus ->
val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null
accountStatus.account.accountId to availableToAddAccount
}
.filter { (_, account) -> account.availableToAddNetworks.isNotEmpty() }
.toMap()
return AvailableToAddWallet(
userWallet = wallet,
accounts = accounts,
availableNetworks = filteredNetworks,
availableToAddAccounts = availableToAddAccounts,
)
}
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet> = balances
.map { entry ->
val (walletId, _) = entry
val availableToAddWallet = getAvailableToAddWallet(entry)
walletId to availableToAddWallet
}
.filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() }
.toMap()
return AvailableToAddData(
availableToAddWallets = availableToAddWallets,
)
}
private fun UserWallet.filteredAvailableNetworks(networks: Set<TokenMarketInfo.Network>) =
filterAvailableNetworksForWalletUseCase(
userWalletId = this.walletId,
networks = networks,
)
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
marketParams: TokenMarketParams,
account: Account,
): CryptoCurrency? {
val derivationIndex = when (account) {
is Account.CryptoPortfolio -> account.derivationIndex
}
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = marketParams,
network = network,
accountIndex = derivationIndex,
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.di
import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface AddToPortfolioComponentModule {
@Binds
fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory
@Binds
fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface AddToPortfolioModelModule {
@Binds
@IntoMap
@ClassKey(AddTokenModel::class)
fun addTokenModel(model: AddTokenModel): Model
@Binds
@IntoMap
@ClassKey(AddToPortfolioModel::class)
fun addToPortfolioModel(model: AddToPortfolioModel): Model
@Binds
@IntoMap
@ClassKey(TokenActionsModel::class)
fun tokenActionsModel(model: TokenActionsModel): Model
@Binds
@IntoMap
@ClassKey(ChooseNetworkModel::class)
fun chooseNetworkModel(model: ChooseNetworkModel): Model
}

View file

@ -0,0 +1,382 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.popToFirst
import com.arkivanov.decompose.router.stack.pushNew
import com.arkivanov.decompose.router.stack.replaceAll
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.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.account.PortfolioSelectorController
import com.tangem.features.feed.components.market.details.portfolio.add.api.*
import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions
import com.tangem.features.feed.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private const val TOKEN_ACTIONS_DELAY = 500L
@ModelScoped
@Suppress("LongParameterList")
internal class AddToPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val callbackDelegate: AddToPortfolioCallbackDelegate,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
val portfolioSelectorController: PortfolioSelectorController,
) : Model(),
ChooseNetworkComponent.Callbacks by callbackDelegate,
TokenActionsComponent.Callbacks by callbackDelegate,
AddTokenComponent.Callbacks by callbackDelegate {
private val params = paramsContainer.require<AddToPortfolioComponent.Params>()
val navigation = StackNavigation<AddToPortfolioRoutes>()
var currentStack = listOf<AddToPortfolioRoutes>(AddToPortfolioRoutes.Empty)
/* Flows that hold state and provide it to child models */
val selectedNetwork: MutableSharedFlow<SelectedNetwork> = replayMutableSharedFlow()
val selectedPortfolio: MutableSharedFlow<SelectedPortfolio> = replayMutableSharedFlow()
val tokenActionsData: MutableSharedFlow<PortfolioData.CryptoCurrencyData> = replayMutableSharedFlow()
private val addToPortfolioManager = params.addToPortfolioManager
val portfolioFetcher = addToPortfolioManager.portfolioFetcher
val eventBuilder = PortfolioAnalyticsEvent.EventBuilder(
token = addToPortfolioManager.token,
source = addToPortfolioManager.analyticsParams?.source,
)
val featureData: Flow<AddToPortfolioManager.State> = combineFeatureData()
init {
navigation.subscribe { currentStack = it.transformer.invoke(currentStack) }
startAddToPortfolioFlow()
}
private fun <T> replayMutableSharedFlow() = MutableSharedFlow<T>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@Suppress("LongMethod")
private fun startAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishFlow() {
params.callback.onDismiss()
channel.close()
}
val featureDataFlow: StateFlow<AvailableToAddData> = featureData
.filterIsInstance<AddToPortfolioManager.State.AvailableToAdd>()
.map { it.availableToAddData }
.distinctUntilChanged()
.stateIn(this)
val isAccountMode = portfolioSelectorController.isAccountModeSync()
// use snapshot data, looks like we dont need to remap at runtime
val data = featureDataFlow.value
// you must control it via [AddToPortfolioManager.state]
if (!data.isAvailableToAdd) {
finishFlow()
return@channelFlow
}
// init data flows, emits on user/code selection, updates state holder
val firstSelectedPortfolio = setupPortfolioFlow(data)
.onEach { selectedPortfolio.emit(it) }
val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio)
.onEach { selectedNetwork.emit(it) }
val isSinglePortfolio = data.isSinglePortfolio
if (isSinglePortfolio) {
val accountId = data.availableToAddWallets.values.first()
.availableToAddAccounts.values.first()
.account.account.accountId
// force select a portfolio, triggers [selectedPortfolio]
portfolioSelectorController.selectAccount(accountId)
} else {
logAccountSelector(isAccountMode)
navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector)
}
val firstPartOfNavigation: Job = firstSelectedPortfolio
.onEach { portfolio ->
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
when {
// force select a network, triggers [selectedNetwork]
isSingleAvailableNetwork -> {
val singleNetwork = portfolio.account.availableToAddNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
}
// it's important to control root screen, UI depends on it(close/arrow icon)
isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio))
else -> navigation.pushNew(routeToNetworkSelector(portfolio))
}
}
.launchIn(this)
// main flow that combine all require data
val allRequireForAdd = combine(
flow = firstSelectedNetwork,
flow2 = firstSelectedPortfolio,
transform = { a, b -> a to b },
)
// suspend until all required data is selected
allRequireForAdd.first()
// line of navigation to AddToken screen is finished; cancel the job, select a new root screen
firstPartOfNavigation.cancel()
navigation.replaceAll(AddToPortfolioRoutes.AddToken)
var middleNavigationJob: Job? = null
// handle actions from AddToken screen
callbackDelegate.onChangeNetworkClick.receiveAsFlow()
.onEach {
middleNavigationJob?.cancel()
middleNavigationJob = changeNetworkNavigationFlow()
.launchIn(this)
val route = routeToNetworkSelector(selectedPortfolio.first())
navigation.pushNew(route)
}
.launchIn(this)
// handle actions from AddToken screen
callbackDelegate.onChangePortfolioClick.receiveAsFlow()
.onEach {
middleNavigationJob?.cancel()
setSelectedAccountToSelectorController()
middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this)
logAccountSelector(isAccountMode)
navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector)
}
.launchIn(this)
// suspend until token is added
val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first()
middleNavigationJob?.cancel()
val selectedPortfolio = selectedPortfolio.first()
messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added)))
setupTokenActionsFlow(selectedPortfolio, addedToken)
.onEach { cryptoCurrencyData ->
tokenActionsData.emit(cryptoCurrencyData)
navigation.replaceAll(AddToPortfolioRoutes.TokenActions)
}
.onEmpty { finishFlow() }
.launchIn(this)
callbackDelegate.onLaterClick.receiveAsFlow().first()
finishFlow()
}
.catch { throwable ->
Timber.e(throwable)
params.callback.onDismiss()
}
.launchIn(modelScope)
}
private fun logAccountSelector(isAccountMode: Boolean) {
if (isAccountMode) {
analyticsEventHandler.send(eventBuilder.popupToChooseAccount())
}
}
private fun changeNetworkNavigationFlow(): Flow<SelectedNetwork> {
return setupNetworkFlow(selectedPortfolio)
.onEach { newNetwork ->
selectedNetwork.emit(newNetwork)
navigation.popToFirst()
}
}
private fun setSelectedAccountToSelectorController() {
modelScope.launch(dispatchers.default) {
val selectedAccount = selectedPortfolio
.first()
.account
.account
.account
.accountId
portfolioSelectorController.selectAccount(selectedAccount)
}
}
private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow<Unit> {
val changedPortfolio = setupPortfolioFlow(data)
.drop(1)
.onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) }
val changedNetwork = setupNetworkFlow(changedPortfolio)
return combine(
flow = changedPortfolio,
flow2 = changedNetwork,
transform = { newPortfolio, newNetwork ->
selectedPortfolio.tryEmit(newPortfolio)
selectedNetwork.tryEmit(newNetwork)
navigation.popToFirst()
},
)
}
private fun setupTokenActionsFlow(
selectedPortfolio: SelectedPortfolio,
addedToken: CryptoCurrencyStatus,
): Flow<PortfolioData.CryptoCurrencyData> {
val timeFlow = channelFlow {
val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) }
getCryptoCurrencyActionsUseCase(
currency = addedToken.currency,
accountId = selectedPortfolio.account.account.account.accountId,
).onEach { state ->
val requestedQuickActions = toQuickActions(state.states)
when {
requestedQuickActions.isNotEmpty() -> {
timerJob.cancel()
send(state)
}
// wait any requestedQuickActions while timer active
timerJob.isActive -> Unit
else -> close()
}
}.collect()
}
return timeFlow.map { actionsState ->
PortfolioData.CryptoCurrencyData(
userWallet = selectedPortfolio.userWallet,
status = actionsState.cryptoCurrencyStatus,
actions = actionsState.states,
)
}
}
private fun setupPortfolioFlow(data: AvailableToAddData): Flow<SelectedPortfolio> = combine(
flow = portfolioSelectorController.isAccountMode,
flow2 = portfolioSelectorController.selectedAccount,
transform = { isAccountMode, selectedAccountId ->
selectedAccountId ?: return@combine null
val availableToAddWallets =
data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null
val availableToAddAccount =
availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null
if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged())
SelectedPortfolio(
isAccountMode = isAccountMode,
userWallet = availableToAddWallets.userWallet,
account = availableToAddAccount,
isAvailableMorePortfolio = !data.isSinglePortfolio,
)
},
)
.filterNotNull()
private fun setupNetworkFlow(selectedPortfolioFlow: Flow<SelectedPortfolio>): Flow<SelectedNetwork> = combine(
flow = selectedPortfolioFlow,
flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(),
transform = transform@{ selectedPortfolio, selectedNetwork ->
SelectedNetwork(
cryptoCurrency = createCryptoCurrency(
userWallet = selectedPortfolio.userWallet,
network = selectedNetwork,
account = selectedPortfolio.account,
) ?: return@transform null,
selectedNetwork = selectedNetwork,
isAvailableMoreNetwork = !selectedPortfolio.account.isSingleNetwork,
)
},
)
.filterNotNull()
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
account: AvailableToAddAccount,
): CryptoCurrency? {
val accountIndex = when (account.account) {
is AccountStatus.CryptoPortfolio -> account.account.account.derivationIndex
}
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = addToPortfolioManager.token,
network = network,
accountIndex = accountIndex,
)
}
private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector {
return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio)
}
private fun combineFeatureData() = addToPortfolioManager.state.onEach { state ->
when (state) {
is AddToPortfolioManager.State.AvailableToAdd ->
portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus ->
val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId]
?: return@isEnabled false
val availableAccount =
availableWallet.availableToAddAccounts[accountStatus.account.accountId]
return@isEnabled availableAccount != null
}
AddToPortfolioManager.State.Init,
AddToPortfolioManager.State.NothingToAdd,
-> Unit
}
}
}
@ModelScoped
internal class AddToPortfolioCallbackDelegate @Inject constructor() :
ChooseNetworkComponent.Callbacks,
TokenActionsComponent.Callbacks,
AddTokenComponent.Callbacks {
val onNetworkSelected = Channel<TokenMarketInfo.Network>()
val onLaterClick = Channel<Unit>()
val onChangeNetworkClick = Channel<Unit>()
val onChangePortfolioClick = Channel<Unit>()
val onTokenAdded = Channel<CryptoCurrencyStatus>()
override fun onNetworkSelected(network: TokenMarketInfo.Network) {
onNetworkSelected.trySend(network)
}
override fun onLaterClick() {
onLaterClick.trySend(Unit)
}
override fun onChangeNetworkClick() {
onChangeNetworkClick.trySend(Unit)
}
override fun onChangePortfolioClick() {
onChangePortfolioClick.trySend(Unit)
}
override fun onTokenAdded(status: CryptoCurrencyStatus) {
onTokenAdded.trySend(status)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.navigation.Route
import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio
import kotlinx.serialization.Serializable
@Serializable
@Immutable
internal sealed interface AddToPortfolioRoutes : Route {
@Serializable
data object Empty : AddToPortfolioRoutes
@Serializable
data object PortfolioSelector : AddToPortfolioRoutes
@Serializable
data class NetworkSelector(
val selectedPortfolio: SelectedPortfolio,
) : AddToPortfolioRoutes
@Serializable
data object AddToken : AddToPortfolioRoutes
@Serializable
data object TokenActions : AddToPortfolioRoutes
}

View file

@ -0,0 +1,118 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import com.tangem.common.ui.addtoken.AddTokenUM
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.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.models.account.Account
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork
import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio
import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress
import com.tangem.features.feed.impl.R
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class AddTokenModel @Inject constructor(
paramsContainer: ParamsContainer,
private val uiBuilder: AddTokenUiBuilder,
private val messageSender: UiMessageSender,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) : Model() {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private val analyticsEventBuilder = params.eventBuilder
private val addTokenJob = JobHolder()
val uiState: StateFlow<AddTokenUM?>
field = MutableStateFlow(value = null)
init {
combine(
flow = params.selectedNetwork.distinctUntilChanged(),
flow2 = params.selectedPortfolio.distinctUntilChanged(),
transform = { selectedNetwork, selectedPortfolio ->
addTokenJob.cancel()
val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio)
uiBuilder.updateContent(
selectedPortfolio = selectedPortfolio,
selectedNetwork = selectedNetwork,
isTangemIconVisible = isTangemIconVisible,
onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) },
)
},
)
.onEach { newUI -> uiState.value = newUI }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onAddClick(selectedNetwork: SelectedNetwork, selectedPortfolio: SelectedPortfolio) =
modelScope.launch(dispatchers.default) {
val um = uiState.value ?: return@launch
uiState.value = um.toggleProgress(true)
val blockchainNames = listOf(selectedNetwork.selectedNetwork)
.mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name }
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames))
val cryptoCurrency = selectedNetwork.cryptoCurrency
val account = selectedPortfolio.account.account.account
val accountId = account.accountId
manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency)
.onLeft { throwable ->
processError(error = throwable)
uiState.value = um.toggleProgress(false)
return@launch
}
val status = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).getOrNull()
if (status == null) {
processError(error = null)
} else {
when (account) {
is Account.CryptoPortfolio -> if (!account.isMainAccount) {
analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount())
}
}
params.callbacks.onTokenAdded(status.status)
}
uiState.value = um.toggleProgress(false)
}
private suspend fun needColdWalletInteraction(
selectedNetwork: SelectedNetwork,
selectedPortfolio: SelectedPortfolio,
): Boolean = coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = selectedPortfolio.userWallet.walletId,
networksWithDerivationPath = mapOf(selectedNetwork.selectedNetwork.networkId to null),
)
private fun processError(error: Throwable?) {
val message = error?.message?.let { stringReference(it) }
?: resourceReference(R.string.common_something_went_wrong)
messageSender.send(ToastMessage(message = message))
}
}

View file

@ -0,0 +1,105 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import com.tangem.common.ui.account.CryptoPortfolioIconUM
import com.tangem.common.ui.account.PortfolioSelectUM
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.addtoken.AddTokenUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.iconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.AccountStatus
import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork
import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio
import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent
import com.tangem.features.feed.impl.R
import javax.inject.Inject
@ModelScoped
internal class AddTokenUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
) {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private fun createNetwork(selectedNetwork: SelectedNetwork): AddTokenUM.Network {
return AddTokenUM.Network(
icon = selectedNetwork.cryptoCurrency.network.iconResId,
name = stringReference(selectedNetwork.cryptoCurrency.network.name),
editable = selectedNetwork.isAvailableMoreNetwork,
onClick = { params.callbacks.onChangeNetworkClick() },
)
}
private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM {
val accountIcon: CryptoPortfolioIconUM?
val portfolioName: TextReference
when (selectedPortfolio.isAccountMode) {
false -> {
accountIcon = null
portfolioName = stringReference(selectedPortfolio.userWallet.name)
}
true -> {
val accountStatus = selectedPortfolio.account.account
portfolioName = accountStatus.account.accountName.toUM().value
accountIcon = when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.account.icon.toUM()
}
}
}
return PortfolioSelectUM(
icon = accountIcon,
name = portfolioName,
isAccountMode = selectedPortfolio.isAccountMode,
isMultiChoice = selectedPortfolio.isAvailableMorePortfolio,
onClick = { params.callbacks.onChangePortfolioClick() },
)
}
fun updateContent(
selectedPortfolio: SelectedPortfolio,
selectedNetwork: SelectedNetwork,
isTangemIconVisible: Boolean,
onConfirmClick: () -> Unit,
): AddTokenUM {
// its may happens when change portfolio after selected both params in line navigation
val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks
.any { selectedNetwork.selectedNetwork.networkId == it.networkId }
val button = AddTokenUM.Button(
isEnabled = isAvailableNetwork,
showProgress = false,
isTangemIconVisible = isTangemIconVisible,
text = resourceReference(R.string.common_add),
onConfirmClick = onConfirmClick,
)
val networkUM = createNetwork(selectedNetwork)
val portfolioUM = createPortfolio(selectedPortfolio)
val currency = selectedNetwork.cryptoCurrency
val tokenToAdd = TokenItemState.Content(
id = currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(currency),
titleState = TokenItemState.TitleState.Content(stringReference(currency.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""),
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return AddTokenUM(
tokenToAdd = tokenToAdd,
network = networkUM,
portfolio = portfolioUM,
button = button,
)
}
companion object {
fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy(
button = this.button.copy(showProgress = showProgress),
)
}
}

View file

@ -0,0 +1,126 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import arrow.core.getOrElse
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.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
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.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM
import com.tangem.features.feed.components.market.details.portfolio.impl.model.BlockchainRowUMConverter
import com.tangem.features.feed.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class ChooseNetworkModel @Inject constructor(
paramsContainer: ParamsContainer,
private val messageSender: UiMessageSender,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<ChooseNetworkComponent.Params>()
val uiState: StateFlow<ChooseNetworkUM> = MutableStateFlow(buildUI())
private fun buildUI(): ChooseNetworkUM {
val allAvailable = params.selectedPortfolio.account.availableNetworks
val alreadyAdded = allAvailable
.subtract(params.selectedPortfolio.account.availableToAddNetworks)
val converter = BlockchainRowUMConverter(
alreadyAddedNetworks = alreadyAdded.mapTo(mutableSetOf()) { it.networkId },
)
val allAvailableNetworks = allAvailable.map { it to true }
return ChooseNetworkUM(
networks = converter.convertList(allAvailableNetworks).toPersistentList(),
onNetworkClick = onNetworkClick@{ row ->
val network = allAvailable
.find { it.networkId == row.id }
?: return@onNetworkClick
checkNetwork(row, network)
},
)
}
private fun checkNetwork(row: BlockchainRowUM, network: TokenMarketInfo.Network) = modelScope.launch {
val selectedWalletId = params.selectedPortfolio.userWallet.walletId
val unsupportedState = checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = row.id,
isMainNetwork = row.isMainNetwork,
)
if (unsupportedState != null) {
showUnsupportedWarning(unsupportedState)
} else {
params.callbacks.onNetworkSelected(network)
}
}
private suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
return checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse { throwable ->
Timber.e(
throwable,
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
)
val message = SnackbarMessage(
message = throwable.localizedMessage?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
}
private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) {
val message = DialogMessage(
message = when (unsupportedState) {
is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
)
messageSender.send(message)
}
}

View file

@ -0,0 +1,76 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class TokenActionsModel @Inject constructor(
paramsContainer: ParamsContainer,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
tokenActionsIntentsFactory: TokenActionsHandler.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val uiBuilder: TokenActionsUiBuilder,
private val analyticsEventHandler: AnalyticsEventHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
) : Model() {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
private val analyticsEventBuilder get() = params.eventBuilder
private val currentAppCurrency = getSelectedAppCurrencyUseCase.invokeOrDefault()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val tokenActionsHandler: TokenActionsHandler =
tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
updateTokenReceiveBSConfig = { },
onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) },
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
val uiState: StateFlow<TokenActionsUM?> = params.data
.mapLatest { uiBuilder.build(it, tokenActionsHandler) }
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) {
val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action)
analyticsEventHandler.send(event)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter
import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler
import javax.inject.Inject
@ModelScoped
internal class TokenActionsUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
private val analyticsEventHandler: AnalyticsEventHandler,
) {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM {
val status = data.status
val tokenUM = TokenItemState.Content(
id = status.currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(status.currency),
titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)),
fiatAmountState = null,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return TokenActionsUM(
token = tokenUM,
onLaterClick = {
analyticsEventHandler.send(params.eventBuilder.getTokenLater())
params.callbacks.onLaterClick()
},
quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler),
)
}
}

View file

@ -0,0 +1,110 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
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.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM
import com.tangem.features.feed.impl.R
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
private const val DISABLED_ALPHA = 0.4f
@Composable
internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.action),
) {
state.networks.fastForEachIndexed { index, model ->
key(model.id) {
BlockchainRow(
model = model,
itemPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing14,
),
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }),
) {
if (!model.isEnabled) {
Label(
modifier = Modifier.alpha(DISABLED_ALPHA),
state = LabelUM(
text = resourceReference(R.string.common_added),
style = LabelStyle.REGULAR,
),
)
}
}
}
}
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) {
TangemThemePreview {
ChooseNetworkContent(
state = content,
)
}
}
internal class ChooseNetworkContentProvider : PreviewParameterProvider<ChooseNetworkUM> {
private val blockchainRow = BlockchainRowUM(
id = UUID.randomUUID().toString(),
name = "Etherium 3",
type = "TEST",
iconResId = R.drawable.img_eth_22,
isMainNetwork = false,
isSelected = true,
isEnabled = true,
)
override val values: Sequence<ChooseNetworkUM>
get() = sequenceOf(
ChooseNetworkUM(
onNetworkClick = {},
networks = persistentListOf(
blockchainRow.copy(
type = "MAIN",
isMainNetwork = true,
),
blockchainRow.copy(
iconResId = R.drawable.ic_bsc_16,
isEnabled = false,
),
blockchainRow.copy(iconResId = R.drawable.img_polygon_22),
blockchainRow.copy(iconResId = R.drawable.img_optimism_22),
),
),
)
}

View file

@ -0,0 +1,72 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.account.PortfolioFetcher
import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
internal class DefaultAddToPortfolioManager @AssistedInject constructor(
private val availableToAddDataConverter: AvailableToAddDataConverter,
@Assisted override val token: TokenMarketParams,
@Assisted override val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?,
@Assisted val scope: CoroutineScope,
dispatchers: CoroutineDispatcherProvider,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : AddToPortfolioManager {
private val _allAvailableNetworks = MutableSharedFlow<List<TokenMarketInfo.Network>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val allAvailableNetworks: Flow<List<TokenMarketInfo.Network>> = _allAvailableNetworks.asSharedFlow()
override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true),
scope = scope,
)
override val state: StateFlow<AddToPortfolioManager.State> =
combine(
flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(),
flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(),
) { balances, availableNetworks ->
val data = availableToAddDataConverter.convert(
balances = balances,
availableNetworks = availableNetworks,
marketParams = token,
)
if (data.isAvailableToAdd) {
AddToPortfolioManager.State.AvailableToAdd(data)
} else {
AddToPortfolioManager.State.NothingToAdd
}
}
.flowOn(dispatchers.default)
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = AddToPortfolioManager.State.Init,
)
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
_allAvailableNetworks.tryEmit(networks)
}
@AssistedFactory
interface Factory : AddToPortfolioManager.Factory {
override fun create(
scope: CoroutineScope,
token: TokenMarketParams,
analyticsParams: MarketsPortfolioComponent.AnalyticsParams?,
): DefaultAddToPortfolioManager
}
}

View file

@ -0,0 +1,204 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import com.tangem.features.feed.impl.R
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@Composable
internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
) {
TokenItem(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
state = state.token,
isBalanceHidden = false,
)
SpacerH(TangemTheme.dimens.spacing14)
Column(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.action),
) {
state.quickActions.actions.fastForEach { actionUM ->
key(actionUM.title) {
ActionRow(
state = actionUM,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) },
)
}
}
}
SpacerH16()
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_later),
onClick = state.onLaterClick,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ActionRow(
state: QuickActionUM,
onClick: () -> Unit,
onLongClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
val onLongClickInternal = {
hapticManager.perform(TangemHapticEffect.View.LongPress)
onLongClick()
}
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable },
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onClick()
},
)
.padding(horizontal = TangemTheme.dimens.spacing12, vertical = TangemTheme.dimens.spacing15),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
val containerColor = TangemTheme.colors.background.action
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = CircleShape,
)
.size(36.dp)
.drawWithContent {
drawContent()
if (state is QuickActionUM.Exchange && state.shouldShowBadge) {
drawBadge(containerColor = containerColor, offset = 4.dp)
}
},
) {
Icon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size16),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(TokenActionsContentPreviewProvider::class) state: TokenActionsUM) {
TangemThemePreview {
TokenActionsContent(
state = state,
)
}
}
private class TokenActionsContentPreviewProvider : PreviewParameterProvider<TokenActionsUM> {
private val tokenState
get() = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(
text = stringReference(value = "Tether"),
),
fiatAmountState = null,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")),
onItemClick = {},
onItemLongClick = {},
)
override val values: Sequence<TokenActionsUM>
get() = sequenceOf(
TokenActionsUM(
quickActions = PortfolioTokenUM.QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),
QuickActionUM.Receive,
),
onQuickActionClick = {},
onQuickActionLongClick = {},
),
token = tokenState,
onLaterClick = {},
),
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import kotlinx.collections.immutable.ImmutableList
data class ChooseNetworkUM(
val networks: ImmutableList<BlockchainRowUM>,
val onNetworkClick: (BlockchainRowUM) -> Unit,
)

View file

@ -0,0 +1,10 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
internal data class TokenActionsUM(
val token: TokenItemState,
val quickActions: PortfolioTokenUM.QuickActions,
val onLaterClick: () -> Unit,
)

View file

@ -0,0 +1,29 @@
package com.tangem.features.feed.components.market.details.portfolio.api
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import kotlinx.serialization.Serializable
@Stable
interface MarketsPortfolioComponent : ComposableContentComponent {
@Serializable
data class Params(
val token: TokenMarketParams,
val analyticsParams: AnalyticsParams?,
)
@Serializable
data class AnalyticsParams(
val source: String,
)
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>)
fun setNoNetworksAvailable()
interface Factory : ComponentFactory<Params, MarketsPortfolioComponent>
}

View file

@ -0,0 +1,89 @@
package com.tangem.features.feed.components.market.details.portfolio.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel
import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.MyPortfolio
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Stable
internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: MarketsPortfolioComponent.Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
) : AppComponentContext by context, MarketsPortfolioComponent {
private val model: MarketsPortfolioModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = MarketsPortfolioRoute.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
model.setTokenNetworks(networks)
}
override fun setNoNetworksAvailable() {
model.setNoNetworksAvailable()
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
MyPortfolio(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnsafeCallOnNullableType")
private fun bottomSheetChild(
config: MarketsPortfolioRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
MarketsPortfolioRoute.AddToPortfolio -> addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.newAddToPortfolioManager!!,
callback = model.addToPortfolioCallback,
),
)
is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config.config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
}
@AssistedFactory
interface Factory : MarketsPortfolioComponent.Factory {
override fun create(
context: AppComponentContext,
params: MarketsPortfolioComponent.Params,
): DefaultMarketsPortfolioComponent
}
}

View file

@ -0,0 +1,74 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
internal class PortfolioAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) {
data class EventBuilder(
val token: TokenMarketParams,
val source: String?,
) {
fun addToPortfolioClicked() = PortfolioAnalyticsEvent(
event = "Button - Add To Portfolio",
params = mapOf(
"Token" to token.symbol,
),
)
fun popupToChooseAccount() = PortfolioAnalyticsEvent(
event = "Popup to choose account",
)
fun addToNotMainAccount() = PortfolioAnalyticsEvent(
event = "Button - Add (token not to main Account)",
)
fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(event = "Wallet Selected")
fun addToPortfolioContinue(blockchainNames: List<String>) = PortfolioAnalyticsEvent(
event = "Token Network Selected",
params = mapOf(
"Count" to blockchainNames.size.toString(),
"Token" to token.symbol,
"blockchain" to blockchainNames.joinToString(separator = ", "),
),
)
fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) =
PortfolioAnalyticsEvent(
event = when (actionUM) {
TokenActionsBSContentUM.Action.Buy -> "Button - Buy"
TokenActionsBSContentUM.Action.Receive -> "Button - Receive"
TokenActionsBSContentUM.Action.Exchange -> "Button - Swap"
TokenActionsBSContentUM.Action.Stake -> "Button - Stake"
TokenActionsBSContentUM.Action.YieldMode -> "Button - Yield Mode"
else -> "error"
},
params = buildMap {
put("Token", token.symbol)
source?.let { put("Source", it) }
put("blockchain", blockchainName)
},
)
fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent(
event = when (actionUM) {
TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy"
TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive"
TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange"
TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake"
else -> "error"
},
)
fun getTokenLater() = PortfolioAnalyticsEvent(
event = "Popup Get token - Button Later",
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.di
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.DefaultMarketsPortfolioComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindMarketsPortfolioComponent(
factory: DefaultMarketsPortfolioComponent.Factory,
): MarketsPortfolioComponent.Factory
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(MarketsPortfolioModel::class)
fun provideMarketsPortfolioModel(model: MarketsPortfolioModel): Model
}

View file

@ -0,0 +1,33 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.loader
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.model.TokenActionsState
/**
* Portfolio data. Combined data from all flows that required to setup portfolio
*
* @property walletsWithCurrencies wallets with crypto currency statuses
* @property appCurrency app currency
* @property isBalanceHidden flag that indicates if balance should be hidden
* @property walletsWithBalance wallets with total balance
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioData(
val walletsWithCurrencies: Map<UserWallet, List<CryptoCurrencyData>>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val walletsWithBalance: Map<UserWalletId, Lce<TokenListError, TotalFiatBalance>>,
) {
data class CryptoCurrencyData(
val userWallet: UserWallet,
val status: CryptoCurrencyStatus,
val actions: List<TokenActionsState.ActionState>,
)
}

View file

@ -0,0 +1,135 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.loader
import arrow.core.getOrElse
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetAllWalletsCryptoCurrencyStatusesUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import javax.inject.Inject
/**
* Loader of portfolio data
*
* @property getAllWalletsCryptoCurrencyStatusesUseCase use case for getting all wallets crypto currency statuses
* @property getSelectedAppCurrencyUseCase use case for getting selected app currency
* @property getBalanceHidingSettingsUseCase use case for getting balance hiding settings
* @property getWalletTotalBalanceUseCase use case for getting wallet total balance
*
[REDACTED_AUTHOR]
*/
internal class PortfolioDataLoader @Inject constructor(
private val getAllWalletsCryptoCurrencyStatusesUseCase: GetAllWalletsCryptoCurrencyStatusesUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase,
private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase,
) {
/** Load data by [currencyRawId] */
@OptIn(ExperimentalCoroutinesApi::class)
fun load(currencyRawId: CryptoCurrency.RawID): Flow<PortfolioData> {
return combine(
flow = getAllWalletsCryptoCurrenciesData(currencyRawId = currencyRawId),
flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { walletsWithCurrencies, appCurrency, isBalanceHidden ->
PortfolioData(
walletsWithCurrencies = walletsWithCurrencies,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
walletsWithBalance = emptyMap(),
)
}
// setup balances for wallets from walletsWithCurrencyStatuses
.flatMapLatest { portfolioData ->
getWalletsWithTotalBalanceFlow(
ids = portfolioData.walletsWithCurrencies.keys.map(UserWallet::walletId),
)
.map { portfolioData.copy(walletsWithBalance = it) }
.onEmpty { emit(portfolioData) }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun getAllWalletsCryptoCurrenciesData(
currencyRawId: CryptoCurrency.RawID,
): Flow<Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>> {
return getAllWalletsCryptoCurrencyStatusesUseCase(currencyRawId)
.distinctUntilChanged()
.map { walletsWithMaybeStatuses ->
walletsWithMaybeStatuses.mapValues { entry ->
entry.value.mapNotNull { it.getOrNull() }
}
}
.flatMapLatest { walletsWithStatuses ->
val actionsFlows = walletsWithStatuses.flatMap { (wallet, statuses) ->
statuses.map { status ->
val yieldSupplyAvailability = yieldSupplyGetAvailabilityUseCase(status.currency).getOrElse {
YieldSupplyAvailability.Unavailable
}
getCryptoCurrencyActionsUseCase(wallet, status, yieldSupplyAvailability)
.map { tokenActionsState ->
PortfolioData.CryptoCurrencyData(
userWallet = wallet,
status = status,
actions = tokenActionsState.states,
)
}
}
}
combine(actionsFlows) { actions ->
walletsWithStatuses.mapValues { entry ->
entry.value.mapNotNull { status ->
actions.firstOrNull {
it.userWallet == entry.key && it.status == status
}
}
}
}.onEmpty {
emit(
walletsWithStatuses.mapValues { (wallet, statuses) ->
statuses.map { status ->
PortfolioData.CryptoCurrencyData(
userWallet = wallet,
status = status,
actions = emptyList(),
)
}
},
)
}
}.onEmpty {
emit(emptyMap())
}
.distinctUntilChanged()
}
private fun getWalletsWithTotalBalanceFlow(
ids: List<UserWalletId>,
): Flow<Map<UserWalletId, Lce<TokenListError, TotalFiatBalance>>> {
return combine(
flows = ids
.map { userWalletId ->
getWalletTotalBalanceUseCase(userWalletId)
.map { userWalletId to it }
.distinctUntilChanged()
},
transform = { it.toMap() },
)
.distinctUntilChanged()
.onEmpty { ids.associateWith { Lce.Loading<TotalFiatBalance>(partialContent = null) } }
}
}

View file

@ -0,0 +1,161 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM
import kotlinx.collections.immutable.toImmutableList
/**
* Factory to create AddToPortfolio bottom sheet content [TangemBottomSheetConfig]
*
* @property token token params
* @property onAddToPortfolioVisibilityChange callback is invoked when add to portfolio visibility is changed
* @property onWalletSelectorVisibilityChange callback is invoked when wallet selector visibility is changed
* @property onNetworkSwitchClick callback is invoked when network switch is clicked
* @property onAnotherWalletSelect callback is invoked when wallet is selected
* @property onContinueClick callback is invoked when continue button is clicked
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class AddToPortfolioBSContentUMFactory(
private val addToPortfolioManager: AddToPortfolioManager,
private val token: TokenMarketParams,
private val onAddToPortfolioVisibilityChange: (Boolean) -> Unit,
private val onWalletSelectorVisibilityChange: (Boolean) -> Unit,
private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit,
private val onAnotherWalletSelect: (UserWalletId) -> Unit,
private val onContinueClick: (selectedWalletId: UserWalletId, addedNetworks: Set<TokenMarketInfo.Network>) -> Unit,
) {
/**
* Create [TangemBottomSheetConfig]
*
* @param portfolioData portfolio data
* @param portfolioUIData portfolio bottom sheet visibility model
* @param selectedWallet selected wallet
* @param alreadyAddedNetworks already added networks
*/
@Suppress("LongParameterList")
fun create(
currentState: TangemBottomSheetConfig?,
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
selectedWallet: UserWallet?,
alreadyAddedNetworks: Set<String>?,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
return (currentState ?: TangemBottomSheetConfig.Empty).copy(
isShown = portfolioUIData.portfolioBSVisibilityModel.isAddToPortfolioBSVisible,
onDismissRequest = { onAddToPortfolioVisibilityChange(false) },
content = if (selectedWallet != null && alreadyAddedNetworks != null) {
AddToPortfolioBSContentUM(
selectedWallet = selectedWallet.toSelectedUserWalletItemUM(
portfolioData = portfolioData,
balance = portfolioData.walletsWithBalance[selectedWallet.walletId]?.getOrNull(),
artwork = artworks[selectedWallet.walletId],
),
selectNetworkUM = SelectNetworkUMConverter(
networksWithToggle = addToPortfolioManager.associateWithToggle(
userWalletId = selectedWallet.walletId,
alreadyAddedNetworkIds = alreadyAddedNetworks,
addToPortfolioData = portfolioUIData.addToPortfolioData,
),
alreadyAddedNetworks = alreadyAddedNetworks,
onNetworkSwitchClick = onNetworkSwitchClick,
).convert(value = token),
isScanCardNotificationVisible = portfolioUIData.isNeededColdWalletInteraction,
isContinueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks(
userWalletId = selectedWallet.walletId,
),
onContinueButtonClick = {
onContinueClick(
selectedWallet.walletId,
portfolioUIData.addToPortfolioData.getAddedNetworks(
userWalletId = selectedWallet.walletId,
alreadyAddedNetworkIds = alreadyAddedNetworks,
),
)
},
walletSelectorConfig = createWalletSelectorBSConfig(
isShow = portfolioUIData.portfolioBSVisibilityModel.isWalletSelectorBSVisible,
portfolioData = portfolioData,
selectedWalletId = selectedWallet.walletId,
artworks = artworks,
),
isWalletBlockVisible = portfolioData.walletsWithCurrencies
.filterKeys(UserWallet::isMultiCurrency).size > 1,
)
} else {
TangemBottomSheetConfigContent.Empty
},
)
}
private fun UserWallet.toSelectedUserWalletItemUM(
artwork: UserWalletItemUM.ImageState? = null,
portfolioData: PortfolioData,
balance: TotalFiatBalance?,
): UserWalletItemUM {
return UserWalletItemUMConverter(
onClick = { onWalletSelectorVisibilityChange(true) },
endIcon = UserWalletItemUM.EndIcon.Arrow,
balance = balance,
artwork = artwork,
appCurrency = portfolioData.appCurrency,
isBalanceHidden = portfolioData.isBalanceHidden,
).convert(value = this)
}
private fun createWalletSelectorBSConfig(
isShow: Boolean,
portfolioData: PortfolioData,
selectedWalletId: UserWalletId,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
return TangemBottomSheetConfig(
isShown = isShow,
onDismissRequest = { onWalletSelectorVisibilityChange(false) },
content = WalletSelectorBSContentUM(
userWallets = portfolioData.walletsWithCurrencies
.filterKeys(UserWallet::isMultiCurrency)
.map { it.key }
.map { userWallet ->
val balance = portfolioData.walletsWithBalance[userWallet.walletId]
UserWalletItemUMConverter(
onClick = { id ->
if (id != selectedWalletId) {
onAnotherWalletSelect(id)
onWalletSelectorVisibilityChange(false)
}
},
appCurrency = portfolioData.appCurrency,
balance = balance?.getOrNull(),
isBalanceHidden = portfolioData.isBalanceHidden,
endIcon = if (userWallet.walletId == selectedWalletId) {
UserWalletItemUM.EndIcon.Checkmark
} else {
UserWalletItemUM.EndIcon.None
},
artwork = artworks[userWallet.walletId],
).convert(userWallet)
}
.toImmutableList(),
onBack = { onWalletSelectorVisibilityChange(false) },
),
)
}
}

View file

@ -0,0 +1,193 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import timber.log.Timber
import javax.inject.Inject
import kotlin.collections.firstOrNull
import kotlin.collections.orEmpty
import kotlin.collections.set
internal typealias WalletsWithNetworks = Map<UserWalletId, Set<TokenMarketInfo.Network>>
/**
* Manager for tracking changing networks in AddToPortfolio
*
[REDACTED_AUTHOR]
*/
internal class AddToPortfolioManager @Inject constructor(
private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase,
) {
val availableNetworks = MutableStateFlow<Set<TokenMarketInfo.Network>?>(value = null)
private val addedNetworks = MutableStateFlow<WalletsWithNetworks>(value = emptyMap())
private val removedNetworks = MutableStateFlow<WalletsWithNetworks>(value = emptyMap())
/** Get [AddToPortfolioData] as flow */
fun getAddToPortfolioData(): Flow<AddToPortfolioData> {
return combine(
flow = availableNetworks,
flow2 = addedNetworks,
flow3 = removedNetworks,
transform = ::AddToPortfolioData,
)
}
/** Set available networks [networks] */
fun setAvailableNetworks(networks: List<TokenMarketInfo.Network>) {
availableNetworks.value = networks.toSet()
}
/** Add network [networkId] to [userWalletId] */
fun addNetwork(userWalletId: UserWalletId, networkId: String) {
addedNetworks.add(userWalletId, networkId)
removedNetworks.cancelPrevChangeIfExist(userWalletId = userWalletId, networkId = networkId)
}
/** Remove network [networkId] from [userWalletId] */
fun removeNetwork(userWalletId: UserWalletId, networkId: String) {
removedNetworks.add(userWalletId, networkId)
addedNetworks.cancelPrevChangeIfExist(
userWalletId = userWalletId,
networkId = networkId,
)
}
/** Remove all networks by [userWalletId] */
fun removeAllChanges(userWalletId: UserWalletId) {
addedNetworks.update {
it.toMutableMap().apply { remove(userWalletId) }
}
removedNetworks.update {
it.toMutableMap().apply { remove(userWalletId) }
}
}
fun associateWithToggle(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
addToPortfolioData: AddToPortfolioData,
): Map<TokenMarketInfo.Network, Boolean> {
val filteredNetworks = filterAvailableNetworksForWalletUseCase(
userWalletId = userWalletId,
networks = addToPortfolioData.availableNetworks.orEmpty(),
)
// Use user choice or check already added networks
return filteredNetworks.associateWith { availableNetwork ->
val isAddedByUser = addToPortfolioData.addedNetworks[userWalletId]?.contains(availableNetwork)
if (isAddedByUser == true) return@associateWith true
val isRemovedByUser = addToPortfolioData.removedNetworks[userWalletId]?.contains(availableNetwork)
if (isRemovedByUser == true) return@associateWith false
val isAddedBefore = alreadyAddedNetworkIds.any { it == availableNetwork.networkId }
isAddedBefore
}
}
private fun MutableStateFlow<WalletsWithNetworks>.cancelPrevChangeIfExist(
userWalletId: UserWalletId,
networkId: String,
) {
if (value[userWalletId].orEmpty().any { it.networkId == networkId }) remove(userWalletId, networkId)
}
private fun MutableStateFlow<WalletsWithNetworks>.add(userWalletId: UserWalletId, networkId: String) {
change(userWalletId = userWalletId, networkId = networkId, isAddAction = true)
}
private fun MutableStateFlow<WalletsWithNetworks>.remove(userWalletId: UserWalletId, networkId: String) {
change(userWalletId = userWalletId, networkId = networkId, isAddAction = false)
}
private fun MutableStateFlow<WalletsWithNetworks>.change(
userWalletId: UserWalletId,
networkId: String,
isAddAction: Boolean,
) {
val network = availableNetworks.value.orEmpty().firstOrNull { it.networkId == networkId }
if (network == null) {
Timber.d(
"Network [$networkId] doesn't contain in available networks [%s]",
availableNetworks.value?.joinToString { it.networkId },
)
return
}
update { walletsWithNetworks ->
walletsWithNetworks.toMutableMap().apply {
this[userWalletId] = if (isAddAction) {
this[userWalletId].orEmpty() + network
} else {
this[userWalletId].orEmpty() - network
}
}
}
}
/**
* Add to portfolio data
*
* @property availableNetworks available networks that user can add to portfolio
* @property addedNetworks networks that user toggled on, but it might have already been added to the wallet
* @property removedNetworks networks that user toggled off, but it might haven't been added to the wallet
*
* Example for [addedNetworks] and [removedNetworks]. This lists will include new networks when user just
* toggle it. But when we will save user changes, we will check what tokens have already been added or
* haven't been added to the wallet. See [getAddedNetworks] and [getRemovedNetworks]
*/
data class AddToPortfolioData(
val availableNetworks: Set<TokenMarketInfo.Network>?,
val addedNetworks: WalletsWithNetworks,
val removedNetworks: WalletsWithNetworks,
) {
fun isUserAddedNetworks(userWalletId: UserWalletId): Boolean {
return addedNetworks[userWalletId].orEmpty().isNotEmpty()
}
fun isUserChangedNetworks(userWalletId: UserWalletId): Boolean {
return addedNetworks[userWalletId].orEmpty().isNotEmpty() ||
removedNetworks[userWalletId].orEmpty().isNotEmpty()
}
/** Get new networks that user [userWalletId] added using [alreadyAddedNetworkIds] */
fun getAddedNetworks(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
): Set<TokenMarketInfo.Network> {
val addedNetworksByUser = addedNetworks[userWalletId].orEmpty()
return addedNetworksByUser.map { it.networkId }
.minus(alreadyAddedNetworkIds)
.mapNotNull { networkId -> addedNetworksByUser.firstOrNull { it.networkId == networkId } }
.toSet()
}
/** Get networks that user [userWalletId] removed using [alreadyAddedNetworkIds] */
fun getRemovedNetworks(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
): Set<TokenMarketInfo.Network> {
val removedNetworksByUser = removedNetworks[userWalletId].orEmpty()
return alreadyAddedNetworkIds
.minus(removedNetworksByUser.map { it.networkId }.toSet())
.mapNotNull { networkId -> removedNetworksByUser.firstOrNull { it.networkId == networkId } }
.toSet()
}
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.getGreyedOutIconRes
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketInfo.Network
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
/**
* Converter from [TokenMarketInfo.Network] to [BlockchainRowUM]
*
* @property alreadyAddedNetworks set of already added networks
*
[REDACTED_AUTHOR]
*/
internal class BlockchainRowUMConverter(
private val alreadyAddedNetworks: Set<String>,
) : Converter<Pair<Network, Boolean>, BlockchainRowUM> {
override fun convert(value: Pair<Network, Boolean>): BlockchainRowUM {
val (network, isSelected) = value
val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId)
?: error("Can't find blockchain info for ${network.networkId}")
val isMainNetwork = network.contractAddress == null
val isEnabled = !alreadyAddedNetworks.contains(network.networkId)
return BlockchainRowUM(
id = network.networkId,
name = blockchainInfo.name,
type = getNetworkType(network, blockchainInfo),
iconResId = if (isEnabled) {
if (isSelected) {
getActiveIconRes(blockchainInfo.blockchainId)
} else {
getGreyedOutIconRes(blockchainInfo.blockchainId)
}
} else {
getGreyedOutIconRes(blockchainInfo.blockchainId)
},
isMainNetwork = isMainNetwork,
isSelected = isSelected,
isEnabled = isEnabled,
)
}
private fun getNetworkType(network: Network, blockchainInfo: BlockchainUtils.BlockchainInfo): String {
val isMainNetwork = network.contractAddress == null
return when {
BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME
isMainNetwork -> MAIN_NETWORK_TYPE_NAME
else -> blockchainInfo.protocolName
}
}
private companion object {
const val MAIN_NETWORK_TYPE_NAME = "MAIN"
const val MAIN_NETWORK_L2_TYPE_NAME = "MAIN L2"
}
}

View file

@ -0,0 +1,440 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
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.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
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.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.markets.SaveMarketTokensUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioDataLoader
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.feed.impl.R
import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle
import com.tangem.features.wallet.utils.UserWalletImageFetcher
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.operations.attestation.ArtworkSize
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager as NewAddToPortfolioManager
@Suppress("LongParameterList", "LargeClass")
@Stable
@ModelScoped
internal class MarketsPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
tokenActionsIntentsFactory: TokenActionsHandler.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val messageSender: UiMessageSender,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
private val portfolioDataLoader: PortfolioDataLoader,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
private val saveMarketTokensUseCase: SaveMarketTokensUseCase,
private val addToPortfolioManager: AddToPortfolioManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle,
private val userWalletImageFetcher: UserWalletImageFetcher,
private val receiveAddressesFactory: ReceiveAddressesFactory,
accountsFeatureToggles: AccountsFeatureToggles,
newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory,
newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory,
) : Model() {
private val _state: MutableStateFlow<MyPortfolioUM> = MutableStateFlow(value = MyPortfolioUM.Loading)
val state: StateFlow<MyPortfolioUM> get() = _state
private val params = paramsContainer.require<MarketsPortfolioComponent.Params>()
private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder(
token = params.token,
source = params.analyticsParams?.source,
)
val newAddToPortfolioManager: NewAddToPortfolioManager?
val newMarketsPortfolioDelegate: NewMarketsPortfolioDelegate?
/** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */
private val selectedMultiWalletIdFlow = MutableStateFlow<UserWalletId?>(value = null)
private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel())
val bottomSheetNavigation: SlotNavigation<MarketsPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
}
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val tokenActionsHandler = tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
updateTokenReceiveBSConfig = { updateBlock ->
if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) {
updateTokensState {
it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig))
}
}
},
onHandleQuickAction = { handledAction ->
analyticsEventHandler.send(
analyticsEventBuilder.quickActionClick(
actionUM = handledAction.action,
blockchainName = handledAction
.cryptoCurrencyData
.status
.currency
.network
.name,
),
)
if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) {
configureReceiveAddresses(handledAction)
}
},
)
private val factory = MyPortfolioUMFactory(
onAddClick = {
onAddToPortfolioBSVisibilityChange(isShow = true)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioClicked(),
)
},
addToPortfolioBSContentUMFactory = AddToPortfolioBSContentUMFactory(
addToPortfolioManager = addToPortfolioManager,
token = params.token,
onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange,
onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange,
onNetworkSwitchClick = ::onNetworkSwitchClick,
onAnotherWalletSelect = { walletId ->
onWalletSelect(walletId)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioWalletChanged(),
)
},
onContinueClick = { selectedWalletId, addedNetworks ->
onContinueClick(selectedWalletId, addedNetworks)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioContinue(
blockchainNames = addedNetworks.mapNotNull {
BlockchainUtils.getNetworkInfo(it.networkId)?.name
},
),
)
},
),
currentState = Provider { _state.value },
tokenActionsHandler = tokenActionsHandler,
updateTokens = { updateBlock ->
updateTokensState { state ->
state.copy(tokens = updateBlock(state.tokens))
}
},
)
init {
if (accountsFeatureToggles.isFeatureEnabled) {
newAddToPortfolioManager = newAddToPortfolioManagerFactory
.create(
modelScope,
params.token,
params.analyticsParams,
)
newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create(
scope = modelScope,
token = params.token,
tokenActionsHandler = tokenActionsHandler,
buttonState = newAddToPortfolioManager.state.map { state ->
when (state) {
is NewAddToPortfolioManager.State.AvailableToAdd -> {
MyPortfolioUM.Tokens.AddButtonState.Available
}
NewAddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading
NewAddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable
}
},
onAddClick = {
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioClicked())
bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio)
},
)
newMarketsPortfolioDelegate.combineData()
.onEach { _state.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
} else {
newAddToPortfolioManager = null
newMarketsPortfolioDelegate = null
// Subscribe on selected wallet flow to support actual selected wallet
subscribeOnSelectedMultiWalletUpdates()
subscribeOnStateUpdates()
}
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
addToPortfolioManager.setAvailableNetworks(networks)
newAddToPortfolioManager?.setTokenNetworks(networks)
newMarketsPortfolioDelegate?.setTokenNetworks(networks)
}
fun setNoNetworksAvailable() {
addToPortfolioManager.setAvailableNetworks(emptyList())
newAddToPortfolioManager?.setTokenNetworks(emptyList())
newMarketsPortfolioDelegate?.setTokenNetworks(emptyList())
}
private fun subscribeOnSelectedMultiWalletUpdates() {
getSelectedWalletUseCase()
.getOrElse { e ->
Timber.e("Failed to load selected wallet: $e")
error("Failed to load selected wallet")
}
.onEach { userWallet ->
selectedMultiWalletIdFlow.value = userWallet.takeIf { it.isMultiCurrency }?.walletId
}
.launchIn(modelScope)
}
private fun subscribeOnStateUpdates() {
combine(
flow = loadPortfolioDataWithArtworks(params.token.id),
flow2 = getPortfolioUIDataFlow(),
transform = { pair, portfolioUIData ->
val (portfolioData, artworks) = pair
factory.create(portfolioData, portfolioUIData, artworks)
},
)
.onEach { _state.value = it }
.launchIn(modelScope)
}
private fun loadPortfolioDataWithArtworks(
currencyRawId: CryptoCurrency.RawID,
): Flow<Pair<PortfolioData, Map<UserWalletId, UserWalletItemUM.ImageState>>> {
val wallets = Channel<Set<UserWallet>>()
val portfolioFlow = portfolioDataLoader
.load(currencyRawId)
.onEach { wallets.trySend(it.walletsWithCurrencies.keys) }
val artworksFlow = wallets.receiveAsFlow()
.distinctUntilChanged()
.flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) }
return combine(
flow = portfolioFlow,
flow2 = artworksFlow,
) { portfolioData, artworks -> portfolioData to artworks }
}
private fun getPortfolioUIDataFlow(): Flow<PortfolioUIData> {
return combine(
flow = portfolioBSVisibilityModelFlow,
flow2 = selectedMultiWalletIdFlow,
flow3 = addToPortfolioManager.getAddToPortfolioData(),
transform = { portfolioBSVisibilityModel, selectedWalletId, addToPortfolioData ->
PortfolioUIData(
portfolioBSVisibilityModel = portfolioBSVisibilityModel,
selectedWalletId = selectedWalletId,
addToPortfolioData = addToPortfolioData,
isNeededColdWalletInteraction = isNeededColdWalletInteraction(selectedWalletId, addToPortfolioData),
)
},
)
}
private suspend fun isNeededColdWalletInteraction(
selectedWalletId: UserWalletId?,
addToPortfolioData: AddToPortfolioManager.AddToPortfolioData,
): Boolean {
return if (selectedWalletId != null) {
coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = selectedWalletId,
networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty()
.associate { it.networkId to null },
)
} else {
false
}
}
private fun onNetworkSwitchClick(blockchainRowUM: BlockchainRowUM, isChecked: Boolean) {
val selectedWalletId = selectedMultiWalletIdFlow.value
if (selectedWalletId == null) {
Timber.e("Impossible to switch network when selected wallet is null")
return
}
if (isChecked) {
modelScope.launch {
val unsupportedState = checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = blockchainRowUM.id,
isMainNetwork = blockchainRowUM.isMainNetwork,
)
if (unsupportedState != null) {
showUnsupportedWarning(unsupportedState)
} else {
addToPortfolioManager.addNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id)
}
}
} else {
addToPortfolioManager.removeNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id)
}
}
private suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
return checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse { throwable ->
Timber.e(
throwable,
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
)
val message = SnackbarMessage(
message = throwable.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
}
private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) {
val message = DialogMessage(
message = when (unsupportedState) {
is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
)
messageSender.send(message)
}
private fun onWalletSelect(userWalletId: UserWalletId) {
selectedMultiWalletIdFlow.update { prevUserWalletId ->
prevUserWalletId?.let(addToPortfolioManager::removeAllChanges)
userWalletId
}
}
private fun onContinueClick(userWalletId: UserWalletId, addedNetworks: Set<TokenMarketInfo.Network>) {
modelScope.launch {
saveMarketTokensUseCase(
userWalletId = userWalletId,
tokenMarketParams = params.token,
addedNetworks = addedNetworks,
removedNetworks = emptySet(),
)
onAddToPortfolioBSVisibilityChange(isShow = false)
addToPortfolioManager.removeAllChanges(userWalletId)
}
}
private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) {
portfolioBSVisibilityModelFlow.update {
it.copy(isAddToPortfolioBSVisible = isShow, isWalletSelectorBSVisible = false)
}
}
private fun onWalletSelectorVisibilityChange(isShow: Boolean) {
portfolioBSVisibilityModelFlow.update {
it.copy(isAddToPortfolioBSVisible = true, isWalletSelectorBSVisible = isShow)
}
}
private fun updateTokensState(block: (MyPortfolioUM.Tokens) -> MyPortfolioUM) {
_state.update { stateToUpdate ->
val tokensState = stateToUpdate as? MyPortfolioUM.Tokens ?: return@update stateToUpdate
block(tokensState)
}
}
private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) {
val isNewReceive = quickAction.action == TokenActionsBSContentUM.Action.Receive &&
tokenReceiveFeatureToggle.isNewTokenReceiveEnabled
if (isNewReceive) {
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = quickAction.cryptoCurrencyData.status,
userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(MarketsPortfolioRoute.TokenReceive(tokenConfig))
}
}
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.models.TokenReceiveConfig
import kotlinx.serialization.Serializable
@Serializable
sealed interface MarketsPortfolioRoute : Route {
@Serializable
data object AddToPortfolio : MarketsPortfolioRoute
@Serializable
data class TokenReceive(
val config: TokenReceiveConfig,
) : MarketsPortfolioRoute
}

View file

@ -0,0 +1,153 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
/**
* Factory for creating [MyPortfolioUM]
*
* @property onAddClick callback when user wants to add new token
* @property addToPortfolioBSContentUMFactory factory for creating add to portfolio bottom sheet content
* @property tokenActionsHandler token actions handler
* @property currentState current state provider
* @property updateTokens callback for updating tokens
*
[REDACTED_AUTHOR]
*/
internal class MyPortfolioUMFactory(
private val onAddClick: () -> Unit,
private val addToPortfolioBSContentUMFactory: AddToPortfolioBSContentUMFactory,
private val tokenActionsHandler: TokenActionsHandler,
private val currentState: Provider<MyPortfolioUM>,
private val updateTokens: ((ImmutableList<PortfolioTokenUM>) -> ImmutableList<PortfolioTokenUM>) -> Unit,
) {
fun create(
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): MyPortfolioUM {
val addToPortfolioData = portfolioUIData.addToPortfolioData
val isOnlyUnavailableNetworks = addToPortfolioData.availableNetworks?.isEmpty() == true
if (isOnlyUnavailableNetworks) return MyPortfolioUM.Unavailable
val walletsWithCurrencies = if (addToPortfolioData.availableNetworks == null) {
portfolioData.walletsWithCurrencies
} else {
portfolioData.walletsWithCurrencies.filterAvailableNetworks(networks = addToPortfolioData.availableNetworks)
}
val isPortfolioEmpty = walletsWithCurrencies.flatMap { it.value }.isEmpty()
if (isPortfolioEmpty) {
val hasMultiWallets = walletsWithCurrencies.filterKeys(UserWallet::isMultiCurrency).isNotEmpty()
return if (hasMultiWallets) {
MyPortfolioUM.AddFirstToken(
addToPortfolioBSConfig = createAddToPortfolioBSConfig(
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
artworks = artworks,
),
onAddClick = onAddClick,
)
} else {
MyPortfolioUM.UnavailableForWallet
}
}
return TokensPortfolioUMConverter(
appCurrency = portfolioData.appCurrency,
isBalanceHidden = portfolioData.isBalanceHidden,
addButtonState = walletsWithCurrencies.getAddButtonState(
availableNetworks = addToPortfolioData.availableNetworks,
),
bsConfig = createAddToPortfolioBSConfig(
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
artworks = artworks,
),
onAddClick = onAddClick,
quickActionsIntents = tokenActionsHandler,
currentState = currentState,
updateTokens = updateTokens,
)
.convert(walletsWithCurrencies)
}
private fun createAddToPortfolioBSConfig(
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
val selectedWallet = portfolioData.walletsWithCurrencies.keys
.firstOrNull { it.walletId == portfolioUIData.selectedWalletId }
?: portfolioData.walletsWithCurrencies.keys.firstOrNull { it.isMultiCurrency }
val availableNetworks = portfolioUIData.addToPortfolioData.availableNetworks.orEmpty()
val alreadyAddedNetworks = portfolioData.walletsWithCurrencies
.filterAvailableNetworks(availableNetworks)[selectedWallet]
?.filter { !it.status.currency.isCustom }
?.map { it.status.currency.network.backendId }
?.toSet()
return addToPortfolioBSContentUMFactory.create(
currentState = currentState().addToPortfolioBSConfig,
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
selectedWallet = selectedWallet,
alreadyAddedNetworks = alreadyAddedNetworks,
artworks = artworks,
)
}
private fun Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>.getAddButtonState(
availableNetworks: Set<TokenMarketInfo.Network>?,
): MyPortfolioUM.Tokens.AddButtonState {
if (availableNetworks == null) return MyPortfolioUM.Tokens.AddButtonState.Loading
val networkIds = availableNetworks.map { it.networkId }
val isAllAvailableNetworksAdded = this
// User can add currencies only in multi-currency wallets
.filterKeys(UserWallet::isMultiCurrency)
.mapValues { entry -> entry.value.map { it.status.currency.network.backendId } }
// Each wallets contains all available networks?
.all { it.value.containsAll(networkIds) }
return if (isAllAvailableNetworksAdded) {
MyPortfolioUM.Tokens.AddButtonState.Unavailable
} else {
MyPortfolioUM.Tokens.AddButtonState.Available
}
}
/** Filter map values by available networks [networks] */
private fun Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>.filterAvailableNetworks(
networks: Set<TokenMarketInfo.Network>,
): Map<UserWallet, List<PortfolioData.CryptoCurrencyData>> {
return mapValues { entry -> entry.value.filterAvailableNetworks(networks) }
}
/** Filter list of [CryptoCurrencyStatus] by available networks [networks] */
private fun List<PortfolioData.CryptoCurrencyData>.filterAvailableNetworks(
networks: Set<TokenMarketInfo.Network>,
): List<PortfolioData.CryptoCurrencyData> {
val networkIds = networks.map(TokenMarketInfo.Network::networkId)
return mapNotNull { cryptoCurrencyData ->
cryptoCurrencyData.takeIf { networkIds.contains(it.status.currency.network.backendId) }
}
}
}

View file

@ -0,0 +1,332 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioHeader
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioListItem
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletHeader
import com.tangem.utils.extensions.isZero
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongParameterList")
internal class NewMarketsPortfolioDelegate @AssistedInject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val allAccountSupplier: MultiAccountStatusListSupplier,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val getUserWalletUseCase: GetUserWalletUseCase,
isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
@Assisted private val scope: CoroutineScope,
@Assisted private val token: TokenMarketParams,
@Assisted private val tokenActionsHandler: TokenActionsHandler,
@Assisted private val buttonState: Flow<MyPortfolioUM.Tokens.AddButtonState>,
@Assisted private val onAddClick: () -> Unit,
) {
private val currencyRawId: CryptoCurrency.RawID = token.id
private var expandedHolder: MutableStateFlow<Set<Pair<UserWalletId, CryptoCurrency.ID>>>? = null
private val settingsFlow: Flow<SettingsBox> = combine(
flow = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
flow3 = isAccountsModeEnabledUseCase(),
transform = ::SettingsBox,
).shareIn(
replay = 1,
started = SharingStarted.Eagerly,
scope = scope,
).distinctUntilChanged()
private val availableNetworks = MutableSharedFlow<List<TokenMarketInfo.Network>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
availableNetworks.tryEmit(networks)
}
fun combineData(): Flow<MyPortfolioUM> {
return availableNetworks.transformLatest { availableNetworks ->
when {
availableNetworks.isEmpty() -> emit(MyPortfolioUM.Unavailable)
else -> emitAll(onAvailableNetworksFlow().distinctUntilChanged())
}
}.distinctUntilChanged()
}
private fun onAvailableNetworksFlow(): Flow<MyPortfolioUM> =
portfolioWithThisCurrencyFLow().transformLatest { portfolioWithCurrency ->
when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) {
false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged())
true -> when (portfolioWithCurrency.hasMultiWallets) {
true -> emitAll(addFirstTokenFlow())
false -> emit(MyPortfolioUM.UnavailableForWallet)
}
}
}
private fun addFirstTokenFlow(): Flow<MyPortfolioUM> = buttonState.map { state ->
when (state) {
MyPortfolioUM.Tokens.AddButtonState.Loading -> MyPortfolioUM.Loading
MyPortfolioUM.Tokens.AddButtonState.Available -> MyPortfolioUM.AddFirstToken(
onAddClick = onAddClick,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
)
MyPortfolioUM.Tokens.AddButtonState.Unavailable -> MyPortfolioUM.Unavailable
}
}
private fun contentFlow(portfoliosWithThisCurrency: PortfoliosWithThisCurrency): Flow<MyPortfolioUM.Content> {
fun Portfolio.actionsFoAccountCurrencies(): List<Flow<Pair<CryptoCurrency, TokenActionsState>>> =
accountsWithAdded.map { account ->
fun CryptoCurrencyStatus.actionsFlow() = getCryptoCurrencyActionsUseCase(
accountId = account.accountStatus.account.accountId,
currency = this.currency,
).map { actionsState -> actionsState.cryptoCurrencyStatus.currency to actionsState }
account.addedCurrency.map { it.actionsFlow() }
}.flatten()
val allAddedTokenActions =
portfoliosWithThisCurrency.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten()
return combine(
flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(),
flow2 = buttonState.distinctUntilChanged(),
flow3 = getExpandedHolder(portfoliosWithThisCurrency),
flow4 = settingsFlow.distinctUntilChanged(),
transform = { actions, addButtonState, expanded, settings ->
buildContentState(
portfolio = portfoliosWithThisCurrency,
allActions = actions,
addButtonState = addButtonState,
expanded = expanded,
settings = settings,
)
},
)
}
private fun getExpandedHolder(
portfolio: PortfoliosWithThisCurrency,
): StateFlow<Set<Pair<UserWalletId, CryptoCurrency.ID>>> {
val expandedHolder = this.expandedHolder
if (expandedHolder != null) return expandedHolder
val allAddedCurrency = portfolio.flattenAddedCurrency
val shouldForceExpand = allAddedCurrency.size == 1 &&
allAddedCurrency.first().value.amount?.isZero() == true
val initValue = when {
shouldForceExpand -> {
val currency = allAddedCurrency.first()
// find userWallet than have this single added token
portfolio.portfolios
.find { it.accountsWithAdded.any { account -> account.addedCurrency.isNotEmpty() } }
?.userWallet
?.let { setOf(it.walletId to currency.currency.id) }
.orEmpty()
}
else -> emptySet()
}
return MutableStateFlow(initValue)
.also { this.expandedHolder = it }
}
private fun portfolioWithThisCurrencyFLow(): Flow<PortfoliosWithThisCurrency> =
allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows ->
combine(flows) { portfolios ->
PortfoliosWithThisCurrency(
currencyRawId = currencyRawId,
portfolios = portfolios.toList(),
)
}
}.distinctUntilChanged()
private fun AccountStatusList.addedAccountsFlow(): Flow<Portfolio> =
getUserWalletUseCase.invokeFlow(this.userWalletId).mapNotNull { it.getOrNull() }.map { wallet ->
Portfolio(
userWallet = wallet,
accountStatusList = this,
accountsWithAdded = this.filterByRawID(),
)
}.distinctUntilChanged()
private fun AccountStatusList.filterByRawID(): List<AccountWithAdded> {
fun AccountStatus.filterByRawID(): List<CryptoCurrencyStatus> = when (this) {
is AccountStatus.CryptoPortfolio -> this.tokenList.flattenCurrencies()
.filter { status -> status.currency.id.rawCurrencyId == currencyRawId }
}
return accountStatuses.map { accountStatus ->
AccountWithAdded(
accountStatus = accountStatus,
addedCurrency = accountStatus.filterByRawID(),
)
}
}
private fun buildContentState(
portfolio: PortfoliosWithThisCurrency,
allActions: Map<CryptoCurrency, TokenActionsState>,
addButtonState: MyPortfolioUM.Tokens.AddButtonState,
expanded: Set<Pair<UserWalletId, CryptoCurrency.ID>>,
settings: SettingsBox,
): MyPortfolioUM.Content {
val appCurrency = settings.appCurrency
val isBalanceHidden = settings.isBalanceHidden
val isAccountMode = settings.isAccountMode
val uiItems: MutableList<PortfolioListItem> = mutableListOf()
fun toggleQuickActions(key: Pair<UserWalletId, CryptoCurrency.ID>) = expandedHolder?.update { expanded ->
val isExpand = expanded.contains(key)
if (isExpand) expanded.minus(key) else expanded.plus(key)
}
val tokenUMConverter = PortfolioTokenUMConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenItemClick = { },
tokenActionsHandler = tokenActionsHandler,
)
portfolio.portfolios.forEach { portfolioItem ->
if (portfolioItem.flattenAddedCurrency.isEmpty()) return@forEach
val userWallet = portfolioItem.userWallet
if (isAccountMode) {
uiItems.add(portfolioItem.userWallet.toWalletHeader())
} else {
uiItems.add(portfolioItem.userWallet.toWalletPortfolioHeader())
}
portfolioItem.accountsWithAdded.forEach { accountWithAdded ->
if (accountWithAdded.addedCurrency.isEmpty()) return@forEach
if (isAccountMode) {
val account = accountWithAdded.accountStatus.account
uiItems.add(account.toAccountPortfolioHeader())
}
accountWithAdded.addedCurrency.forEach { currencyStatus ->
val actions = allActions[currencyStatus.currency]?.states.orEmpty()
val value = PortfolioData.CryptoCurrencyData(
userWallet = userWallet,
status = currencyStatus,
actions = actions,
)
val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id
val isExpand = expanded.contains(expandedKey)
val tokenItem = tokenUMConverter.convertV2(
onTokenItemClick = { wallet, status ->
toggleQuickActions(wallet.walletId to status.currency.id)
},
value = value,
isQuickActionsShown = isExpand,
)
uiItems.add(tokenItem)
}
}
}
return MyPortfolioUM.Content(
items = uiItems.toImmutableList(),
buttonState = addButtonState,
onAddClick = onAddClick,
)
}
private fun Account.toAccountPortfolioHeader(): PortfolioHeader = PortfolioHeader(
id = this.accountId.value,
state = AccountTitleUM.Account(
prefixText = TextReference.EMPTY,
name = this.accountName.toUM().value,
icon = when (this) {
is Account.CryptoPortfolio -> this.icon.toUM()
},
),
)
private fun UserWallet.toWalletPortfolioHeader(): PortfolioHeader = PortfolioHeader(
id = this.walletId.stringValue,
state = AccountTitleUM.Text(
title = stringReference(this.name),
),
)
private fun UserWallet.toWalletHeader(): WalletHeader = WalletHeader(
id = this.walletId.stringValue,
name = stringReference(this.name),
)
@Suppress("LongParameterList")
@AssistedFactory
interface Factory {
fun create(
scope: CoroutineScope,
token: TokenMarketParams,
tokenActionsHandler: TokenActionsHandler,
buttonState: Flow<MyPortfolioUM.Tokens.AddButtonState>,
onAddClick: () -> Unit,
): NewMarketsPortfolioDelegate
}
}
private data class PortfoliosWithThisCurrency(
val currencyRawId: CryptoCurrency.RawID,
val portfolios: List<Portfolio>,
) {
val hasMultiWallets: Boolean = portfolios.any { it.userWallet.isMultiCurrency }
val flattenAddedCurrency: List<CryptoCurrencyStatus> =
portfolios.map { portfolio -> portfolio.flattenAddedCurrency }.flatten()
}
private data class Portfolio(
val userWallet: UserWallet,
val accountStatusList: AccountStatusList,
val accountsWithAdded: List<AccountWithAdded>,
) {
val flattenAddedCurrency: List<CryptoCurrencyStatus> =
accountsWithAdded.map { it.addedCurrency }.flatten()
}
private data class AccountWithAdded(
val addedCurrency: List<CryptoCurrencyStatus>,
val accountStatus: AccountStatus,
)
private data class SettingsBox(
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val isAccountMode: Boolean,
)

View file

@ -0,0 +1,14 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
/**
* Model for portfolio bottom sheet visibility
*
* @property isAddToPortfolioBSVisible visibility of add to portfolio bottom sheet
* @property isWalletSelectorBSVisible visibility of wallet selector bottom sheet
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioBSVisibilityModel(
val isAddToPortfolioBSVisible: Boolean = false,
val isWalletSelectorBSVisible: Boolean = false,
)

View file

@ -0,0 +1,124 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM]
*
[REDACTED_AUTHOR]
*/
internal class PortfolioTokenUMConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit,
private val tokenActionsHandler: TokenActionsHandler,
) : Converter<PortfolioData.CryptoCurrencyData, PortfolioTokenUM> {
fun convertV2(
value: PortfolioData.CryptoCurrencyData,
isQuickActionsShown: Boolean,
onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit,
): PortfolioTokenUM {
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
onItemClick = { _, status -> onTokenItemClick(value.userWallet, status) },
)
return PortfolioTokenUM(
tokenItemState = tokenItemStateConverter.convert(value = value.status),
walletId = value.userWallet.walletId,
isBalanceHidden = isBalanceHidden,
isQuickActionsShown = isQuickActionsShown,
quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler),
)
}
override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM {
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) },
subtitleStateProvider = {
TokenItemState.SubtitleState.TextContent(value = stringReference(value.status.currency.name))
},
onItemClick = { _, status -> onTokenItemClick(status) },
)
return PortfolioTokenUM(
tokenItemState = tokenItemStateConverter.convert(value = value.status),
walletId = value.userWallet.walletId,
isBalanceHidden = isBalanceHidden,
isQuickActionsShown = false,
quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler),
)
}
companion object {
fun quickActions(
cryptoData: PortfolioData.CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
): PortfolioTokenUM.QuickActions {
return PortfolioTokenUM.QuickActions(
actions = toQuickActions(cryptoData.actions),
onQuickActionClick = { quickActionUM ->
when (quickActionUM) {
QuickActionUM.Buy -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Buy,
cryptoCurrencyData = cryptoData,
)
is QuickActionUM.Exchange -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Exchange,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Receive -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Receive,
cryptoCurrencyData = cryptoData,
)
QuickActionUM.Stake -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.Stake,
cryptoCurrencyData = cryptoData,
)
is QuickActionUM.YieldMode -> tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.YieldMode,
cryptoCurrencyData = cryptoData,
)
}
},
onQuickActionLongClick = { actionUM ->
if (actionUM == QuickActionUM.Receive) {
tokenActionsHandler.handle(
action = TokenActionsBSContentUM.Action.CopyAddress,
cryptoCurrencyData = cryptoData,
)
}
},
)
}
fun toQuickActions(actions: List<TokenActionsState.ActionState>) = buildList {
actions.forEach { action ->
if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) {
when (action) {
is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy
is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.showBadge)
is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive
is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake
is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy)
else -> null
}?.let(::add)
}
}
}.toImmutableList()
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.domain.models.wallet.UserWalletId
/**
* Portfolio UI data. Combined data from all UI flows that required to setup portfolio
*
* @property portfolioBSVisibilityModel portfolio bottom sheet visibility model
* @property selectedWalletId selected wallet id
* @property addToPortfolioData add to portfolio data
* @property isNeededColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioUIData(
val portfolioBSVisibilityModel: PortfolioBSVisibilityModel,
val selectedWalletId: UserWalletId?,
val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData,
val isNeededColdWalletInteraction: Boolean,
)

View file

@ -0,0 +1,37 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from [TokenMarketParams] to [SelectNetworkUM]
*
* @property networksWithToggle map of networks with toggles
* @property alreadyAddedNetworks already added networks
* @property onNetworkSwitchClick callback is called when network switch is clicked
*
[REDACTED_AUTHOR]
*/
internal class SelectNetworkUMConverter(
private val networksWithToggle: Map<TokenMarketInfo.Network, Boolean>,
private val alreadyAddedNetworks: Set<String>,
private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit,
) : Converter<TokenMarketParams, SelectNetworkUM> {
override fun convert(value: TokenMarketParams): SelectNetworkUM {
return SelectNetworkUM(
tokenId = value.id.value,
iconUrl = value.imageUrl,
tokenName = value.name,
tokenCurrencySymbol = value.symbol,
networks = BlockchainRowUMConverter(alreadyAddedNetworks)
.convertList(networksWithToggle.toList())
.toImmutableList(),
onNetworkSwitchClick = { um, isChecked -> onNetworkSwitchClick(um, isChecked) },
)
}
}

View file

@ -0,0 +1,239 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.feed.impl.R
import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
private val shareManager: ShareManager,
private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase,
) {
private val disabledActionsInDemoMode = buildSet {
add(TokenActionsBSContentUM.Action.Sell)
}
fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
onHandleQuickAction(
HandledQuickAction(
action = action,
cryptoCurrencyData = cryptoCurrencyData,
),
)
val userWallet = cryptoCurrencyData.userWallet
if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return
when (action) {
TokenActionsBSContentUM.Action.Buy -> onBuyClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Exchange -> onExchangeClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Receive -> onReceiveClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.CopyAddress -> onCopyAddress(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Sell -> onSellClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Send -> onSendClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Stake -> onStakeClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.YieldMode -> onYieldModeClick(cryptoCurrencyData)
}
}
private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean {
val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId)
val isNeededShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action)
if (isNeededShowDemoWarning) {
showDemoModeWarning()
}
return isNeededShowDemoWarning
}
private fun showDemoModeWarning() {
val message = DialogMessage(
message = resourceReference(R.string.alert_demo_feature_disabled),
)
messageSender.send(message)
}
private fun onReceiveClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val cryptoCurrencyStatus = cryptoCurrencyData.status
val currency = cryptoCurrencyStatus.currency
val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return
updateTokenReceiveBSConfig {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {
updateTokenReceiveBSConfig {
it.copy(isShown = false)
}
},
content = TokenReceiveBottomSheetConfig(
asset = TokenReceiveBottomSheetConfig.Asset.Currency(
name = currency.name,
symbol = currency.symbol,
),
network = currency.network,
networkAddress = networkAddress,
showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE,
onCopyClick = { clipboardManager.setText(networkAddress.defaultAddress.value, isSensitive = true) },
onShareClick = { shareManager.shareText(networkAddress.defaultAddress.value) },
),
)
}
}
private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val cryptoCurrencyStatus = cryptoCurrencyData.status
val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return
val addresses = networkAddress.availableAddresses
.mapToAddressModels(cryptoCurrencyStatus.currency)
.toImmutableList()
val defaultAddress = addresses.firstOrNull()?.value ?: return
clipboardManager.setText(text = defaultAddress, isSensitive = true)
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied)))
}
private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
AppRoute.Onramp(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
source = OnrampSource.MARKETS,
),
)
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
reduxStateHolder.dispatch(
TradeCryptoAction.Sell(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
),
)
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
AppRoute.Swap(
currencyFrom = cryptoCurrencyData.status.currency,
userWalletId = cryptoCurrencyData.userWallet.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.Markets.value,
),
)
}
private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val route = AppRoute.SendEntryPoint(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
)
router.push(route)
}
private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake }
?.let { it as TokenActionsState.ActionState.Stake }
?.option ?: return
router.push(
AppRoute.Staking(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrency = cryptoCurrencyData.status.currency,
integrationId = option.integrationId,
),
)
}
private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance<TokenActionsState.ActionState.YieldMode>()
.firstOrNull()?.apy ?: return
val (userWalletId, cryptoCurrencyStatus) = cryptoCurrencyData.let { currencyData ->
currencyData.userWallet.walletId to currencyData.status
}
val tokenEnterStatus = yieldSupplyEnterStatusUseCase(userWalletId, cryptoCurrencyStatus).getOrNull()
val isActiveYield = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true
when {
tokenEnterStatus != null -> router.push(
AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = cryptoCurrencyStatus.currency,
navigationAction = NavigationAction.YieldSupply(
isActive = isActiveYield,
),
),
)
isActiveYield -> {
router.push(
AppRoute.YieldSupplyActive(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
apy = yieldSupplyApy,
),
)
}
else -> {
router.push(
AppRoute.YieldSupplyPromo(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
apy = yieldSupplyApy,
),
)
}
}
}
@AssistedFactory
interface Factory {
fun create(
currentAppCurrency: Provider<AppCurrency>,
updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit,
onHandleQuickAction: (HandledQuickAction) -> Unit,
): TokenActionsHandler
}
data class HandledQuickAction(
val action: TokenActionsBSContentUM.Action,
val cryptoCurrencyData: PortfolioData.CryptoCurrencyData,
)
}

View file

@ -0,0 +1,116 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlin.collections.map
/**
* Converter from [Map] of [UserWallet] and [CryptoCurrencyStatus] to [MyPortfolioUM.Tokens]
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class TokensPortfolioUMConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val addButtonState: MyPortfolioUM.Tokens.AddButtonState,
private val bsConfig: TangemBottomSheetConfig,
private val onAddClick: () -> Unit,
private val quickActionsIntents: TokenActionsHandler,
private val currentState: Provider<MyPortfolioUM>,
private val updateTokens: ((ImmutableList<PortfolioTokenUM>) -> ImmutableList<PortfolioTokenUM>) -> Unit,
) : Converter<Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>, MyPortfolioUM.Tokens> {
override fun convert(value: Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>): MyPortfolioUM.Tokens {
val currentTokensState = currentState() as? MyPortfolioUM.Tokens
return MyPortfolioUM.Tokens(
tokens = value
.flatMap { entry -> entry.value }
.map { cryptoData ->
PortfolioTokenUMConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenItemClick = { toggleQuickActions(cryptoData) },
tokenActionsHandler = quickActionsIntents,
).convert(value = cryptoData) to cryptoData
}
.setQuickActionsVisibility(currentState = currentTokensState)
.toImmutableList(),
buttonState = addButtonState,
addToPortfolioBSConfig = bsConfig,
onAddClick = onAddClick,
tokenReceiveBSConfig = (currentState() as? MyPortfolioUM.Tokens)
?.tokenReceiveBSConfig
?: TangemBottomSheetConfig.Empty,
)
}
private fun List<Pair<PortfolioTokenUM, PortfolioData.CryptoCurrencyData>>.setQuickActionsVisibility(
currentState: MyPortfolioUM.Tokens?,
): List<PortfolioTokenUM> {
return when {
// if there is only one token and it has empty balance, show quick actions for it
currentState == null && this.size == 1 && isEmptyBalance(this.first().second) -> {
this.map { (token, _) ->
token.copy(isQuickActionsShown = true)
}
}
// if there is no previous state, hide quick actions for all tokens
currentState == null -> {
this.map { (token, _) ->
token.copy(isQuickActionsShown = false)
}
}
else -> {
val previousList = currentState.tokens
// otherwise, keep previous state
this.map { (token, _) ->
token.copy(
isQuickActionsShown = previousList
.firstOrNull { it.matchWith(token) }
?.isQuickActionsShown == true,
)
}
}
}
}
private fun isEmptyBalance(cryptoData: PortfolioData.CryptoCurrencyData): Boolean {
return cryptoData.status.value.amount?.isZero() == true
}
private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) {
updateTokens { tokenList ->
tokenList.map { portfolioTokenUM ->
portfolioTokenUM.copy(
isQuickActionsShown = if (portfolioTokenUM.matchWith(cryptoData)) {
!portfolioTokenUM.isQuickActionsShown
} else {
false
},
)
}.toImmutableList()
}
}
private fun PortfolioTokenUM.matchWith(token: PortfolioTokenUM): Boolean {
return this.walletId == token.walletId && this.tokenItemState.id == token.tokenItemState.id
}
private fun PortfolioTokenUM.matchWith(cryptoData: PortfolioData.CryptoCurrencyData): Boolean {
return this.walletId == cryptoData.userWallet.walletId &&
this.tokenItemState.id == cryptoData.status.currency.id.value
}
}

View file

@ -0,0 +1,383 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
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.util.fastForEachIndexed
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.rows.ArrowRow
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM
import com.tangem.features.feed.impl.R
import kotlinx.coroutines.delay
@Composable
internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<AddToPortfolioBSContentUM>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
addBottomInsets = false,
titleText = resourceReference(R.string.common_add_to_portfolio),
) { contentUM ->
Content(
modifier = Modifier.fillMaxWidth(),
state = contentUM,
)
WalletSelectorBottomSheet(contentUM.walletSelectorConfig)
}
}
@Composable
private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modifier) {
var continueButtonAreaHeight by remember { mutableIntStateOf(0) }
val density = LocalDensity.current
val scrollState = rememberScrollState()
Box(modifier = modifier) {
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
if (state.isWalletBlockVisible) {
UserWalletItem(
state = state.selectedWallet,
blockColors = TangemBlockCardColors.copy(
containerColor = TangemTheme.colors.background.action,
disabledContainerColor = TangemTheme.colors.background.action,
),
)
SpacerH12()
}
NetworkSelection(
modifier = Modifier.fillMaxWidth(),
state = state.selectNetworkUM,
)
SpacerH12()
AnimatedVisibility(
visible = state.isScanCardNotificationVisible,
modifier = Modifier.fillMaxWidth(),
) {
Column {
ScanWalletWarning(modifier = Modifier.fillMaxWidth())
SpacerH12()
}
// Scroll to the bottom when the notification appears and the scroll is at the bottom
LaunchedEffect(Unit) {
if (scrollState.canScrollForward.not()) {
delay(timeMillis = 500)
scrollState.animateScrollTo(scrollState.maxValue)
}
}
}
SpacerH(with(density) { continueButtonAreaHeight.toDp() })
}
AnimatedVisibility(
visible = scrollState.canScrollForward,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
BottomFade(Modifier.align(Alignment.BottomCenter))
}
ContinueButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.onGloballyPositioned {
continueButtonAreaHeight = it.size.height
},
enabled = state.isContinueButtonEnabled,
isTangemIconVisible = state.isScanCardNotificationVisible,
onClick = state.onContinueButtonClick,
)
}
}
@Composable
private fun ContinueButton(
enabled: Boolean,
isTangemIconVisible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemButton(
enabled = enabled,
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
)
.navigationBarsPadding()
.fillMaxWidth(),
text = stringResourceSafe(R.string.common_continue),
icon = if (enabled && isTangemIconVisible) {
TangemButtonIconPosition.End(R.drawable.ic_tangem_24)
} else {
TangemButtonIconPosition.None
},
showProgress = false,
size = TangemButtonSize.Default,
colors = TangemButtonsDefaults.primaryButtonColors,
textStyle = TangemTheme.typography.subtitle1,
onClick = onClick,
animateContentChange = true,
)
}
@Suppress("LongMethod")
@Composable
private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifier) {
val hapticManager = LocalHapticManager.current
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(R.string.markets_select_network),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing14),
verticalAlignment = Alignment.CenterVertically,
) {
CoinIcon(
modifier = Modifier.size(TangemTheme.dimens.size36),
url = state.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
SpacerW12()
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.weight(1f, fill = false)
.alignByBaseline(),
text = state.tokenName,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW6()
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.alignByBaseline(),
text = state.tokenCurrencySymbol,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Visible,
maxLines = 1,
)
}
state.networks.fastForEachIndexed { index, network ->
ArrowRow(
isLastItem = index == state.networks.lastIndex,
content = {
BlockchainRow(
modifier = Modifier.padding(end = TangemTheme.dimens.spacing4),
model = network,
action = {
TangemSwitch(
checked = network.isSelected,
checkedColor = if (network.isEnabled) {
TangemTheme.colors.control.checked
} else {
TangemTheme.colors.icon.inactive
},
onCheckedChange = { checked ->
if (checked) {
hapticManager.perform(TangemHapticEffect.View.ToggleOn)
} else {
hapticManager.perform(TangemHapticEffect.View.ToggleOff)
}
state.onNetworkSwitchClick(network, checked)
},
enabled = network.isEnabled,
)
},
)
},
)
}
}
}
}
@Composable
private fun ScanWalletWarning(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(
color = TangemTheme.colors.button.disabled,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10),
) {
Icon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size20),
imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.markets_generate_addresses_notification),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview {
AddToPortfolioBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
content = content,
onDismissRequest = {},
),
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun PreviewContent(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview {
Content(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.fillMaxWidth(),
state = content,
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun PreviewContentRtl(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview(rtl = true) {
Content(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.fillMaxWidth(),
state = content,
)
}
}
// For on device testing
@Composable
@Preview
private fun PreviewContentTestOnDevice(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview(
alwaysShowBottomSheets = false,
) {
var isShow by remember { mutableStateOf(false) }
var contentState by remember {
mutableStateOf(content)
}
LaunchedEffect(Unit) {
contentState = content.copy(
onContinueButtonClick = {
contentState = contentState.copy(
isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible,
)
},
isContinueButtonEnabled = true,
selectedWallet = content.selectedWallet.copy(
onClick = {
contentState = contentState.copy(
isContinueButtonEnabled = !contentState.isContinueButtonEnabled,
)
},
),
)
}
AddToPortfolioBottomSheet(
config = TangemBottomSheetConfig(
isShown = isShow,
content = contentState,
onDismissRequest = { isShow = false },
),
)
Button(
onClick = { isShow = !isShow },
) {
Text(text = "Toggle")
}
}
}

View file

@ -0,0 +1,341 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.resolveReference
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.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.*
import com.tangem.features.feed.impl.R
@Composable
internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) {
if (state is MyPortfolioUM.Content) {
val contentModifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing20,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing32,
)
PortfolioList(state, contentModifier)
return
}
InformationBlock(
modifier = modifier,
contentHorizontalPadding = TangemTheme.dimens.spacing0,
title = { Title() },
action = {
if (state !is MyPortfolioUM.Tokens) return@InformationBlock
AddButton(state = state.buttonState, onClick = state.onAddClick)
},
) {
val contentModifier = Modifier.padding(
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
)
when (state) {
is MyPortfolioUM.Tokens -> TokenList(state = state)
is MyPortfolioUM.AddFirstToken -> AddFirstTokenContent(state = state, modifier = contentModifier)
MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier)
MyPortfolioUM.Unavailable -> UnavailableAsset(modifier = contentModifier)
MyPortfolioUM.UnavailableForWallet -> UnavailableAssetForWallet(modifier = contentModifier)
is MyPortfolioUM.Content -> PortfolioList(state = state)
}
}
val bsConfig = state.addToPortfolioBSConfig
if (bsConfig != null) {
AddToPortfolioBottomSheet(config = bsConfig)
}
}
@Composable
private fun Title() {
Text(
text = stringResourceSafe(R.string.markets_common_my_portfolio),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun AddButton(state: MyPortfolioUM.Tokens.AddButtonState, onClick: () -> Unit) {
when (state) {
MyPortfolioUM.Tokens.AddButtonState.Loading -> {
Box {
SmallButtonShimmer(
modifier = Modifier.width(width = TangemTheme.dimens.size63),
shape = RoundedCornerShape(TangemTheme.dimens.radius3),
withIcon = true,
)
Box(
Modifier
.matchParentSize()
.background(TangemTheme.colors.background.action),
)
RectangleShimmer(
modifier = Modifier
.align(Alignment.Center)
.size(width = TangemTheme.dimens.size63, height = TangemTheme.dimens.size18),
radius = TangemTheme.dimens.radius3,
)
}
}
MyPortfolioUM.Tokens.AddButtonState.Available,
MyPortfolioUM.Tokens.AddButtonState.Unavailable,
-> {
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.markets_add_token),
icon = TangemButtonIconPosition.Start(R.drawable.ic_plus_24),
onClick = onClick,
isEnabled = state == MyPortfolioUM.Tokens.AddButtonState.Available,
),
)
}
}
}
@Composable
private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier) {
Column(modifier) {
state.tokens.fastForEachIndexed { index, token ->
key(token.tokenItemState.id) {
PortfolioItem(
modifier = Modifier.background(color = TangemTheme.colors.background.action),
state = token,
lastInList = index == state.tokens.size - 1,
)
}
}
}
TokenReceiveBottomSheet(config = state.tokenReceiveBSConfig)
}
@Composable
private fun PortfolioList(state: MyPortfolioUM.Content, modifier: Modifier = Modifier) {
Column(modifier) {
key("PortfolioListHeader") {
Row(
modifier = Modifier.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(R.string.markets_common_my_portfolio),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
AddButton(state = state.buttonState, onClick = state.onAddClick)
}
}
state.items.fastForEachIndexed { index, item ->
val previousItem = state.items.getOrNull(index.dec())
val nextItem = state.items.getOrNull(index.inc())
val itemModifier = Modifier
.fillMaxWidth()
.getOffsetModifier(item, previousItem)
.getBackgroundModifier(item, previousItem, nextItem)
key(item.id) {
PortfolioItem(
item = item,
modifier = itemModifier,
lastInList = index == state.items.size - 1,
)
}
}
}
}
@Composable
private fun Modifier.getBackgroundModifier(
item: PortfolioListItem,
previousItem: PortfolioListItem?,
nextItem: PortfolioListItem?,
): Modifier {
val color = TangemTheme.colors.background.action
val radius = 14.dp
val topRound = RoundedCornerShape(topStart = radius, topEnd = radius)
val bottomRound = RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
val allRound = RoundedCornerShape(size = radius)
val backgroundModifier = when (item) {
is WalletHeader -> this
is PortfolioHeader -> this
.clip(topRound)
.background(color = color)
is PortfolioTokenUM -> when {
previousItem is PortfolioHeader && nextItem !is PortfolioTokenUM -> this
.clip(bottomRound)
.background(color = color)
previousItem is WalletHeader && nextItem !is PortfolioTokenUM -> this
.clip(allRound)
.background(color = color)
previousItem is PortfolioTokenUM && nextItem !is PortfolioTokenUM -> this
.clip(bottomRound)
.background(color = color)
else -> this.background(color = color)
}
}
return backgroundModifier
}
private fun Modifier.getOffsetModifier(item: PortfolioListItem, previousItem: PortfolioListItem?): Modifier = when {
item is WalletHeader -> this.padding(top = 20.dp, start = 4.dp, end = 4.dp)
item is PortfolioHeader && previousItem is PortfolioTokenUM -> this.padding(top = 12.dp)
item is PortfolioHeader && previousItem == null -> this.padding(top = 20.dp)
previousItem is WalletHeader -> this.padding(top = 12.dp)
previousItem is PortfolioTokenUM -> this
else -> this
}
@Composable
private fun PortfolioItem(item: PortfolioListItem, lastInList: Boolean, modifier: Modifier = Modifier) {
when (item) {
is PortfolioHeader -> AccountTitle(
modifier = modifier.padding(
start = 12.dp,
top = 12.dp,
bottom = 8.dp,
),
accountTitleUM = item.state,
textStyle = TangemTheme.typography.caption1,
textColor = TangemTheme.colors.text.primary1,
)
is PortfolioTokenUM -> PortfolioItem(
state = item,
modifier = modifier,
lastInList = lastInList,
)
is WalletHeader -> Text(
modifier = modifier,
text = item.name.resolveReference(),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
}
}
@Composable
fun UnavailableAsset(modifier: Modifier = Modifier) {
UnavailableContent(
textId = R.string.markets_add_to_my_portfolio_unavailable_description,
modifier = modifier,
)
}
@Composable
fun UnavailableAssetForWallet(modifier: Modifier = Modifier) {
UnavailableContent(
textId = R.string.markets_add_to_my_portfolio_unavailable_for_wallet_description,
modifier = modifier,
)
}
@Composable
private fun UnavailableContent(@StringRes textId: Int, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = stringResourceSafe(textId),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
text = stringResourceSafe(R.string.markets_add_to_my_portfolio_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_add_to_portfolio),
onClick = state.onAddClick,
)
}
}
@Composable
private fun LoadingPlaceholder(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.7f),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) {
TangemThemePreview {
Box(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary),
) {
MyPortfolio(state)
}
}
}
@Preview
@Composable
private fun PreviewRtl(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) {
TangemThemePreview(rtl = true) {
Box(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing8),
) {
MyPortfolio(state)
}
}
}

View file

@ -0,0 +1,153 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.icons.IconTint
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.features.feed.impl.R
import com.tangem.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState
@Composable
internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifier: Modifier = Modifier) {
Column(modifier) {
val hapticManager = LocalHapticManager.current
val tokenItemState = remember(state.tokenItemState) {
when (state.tokenItemState) {
is TokenItemState.Content -> state.tokenItemState.copy(
onItemClick = { tokenItemState ->
val onClick = state.tokenItemState.onItemClick
if (onClick != null) {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
onClick.invoke(tokenItemState)
}
},
)
else -> state.tokenItemState
}
}
TokenItem(
state = tokenItemState,
isBalanceHidden = state.isBalanceHidden,
itemPaddingValues = PaddingValues(
start = TangemTheme.dimens.spacing10,
end = TangemTheme.dimens.spacing12,
),
)
PortfolioQuickActions(
modifier = Modifier
.padding(
bottom = if (lastInList) {
TangemTheme.dimens.spacing12
} else {
TangemTheme.dimens.spacing24
},
),
actions = state.quickActions.actions,
isVisible = state.isQuickActionsShown,
onActionClick = state.quickActions.onQuickActionClick,
onActionLongClick = state.quickActions.onQuickActionLongClick,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: PortfolioTokenUM) {
TangemThemePreview {
var isQuickActionsShown by remember { mutableStateOf(value = false) }
val onItemClick = {
isQuickActionsShown = isQuickActionsShown.not()
}
PortfolioItem(
modifier = Modifier.background(color = TangemTheme.colors.background.action),
state = tokenUM.copy(
tokenItemState = when (tokenUM.tokenItemState) {
is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() })
is TokenItemState.Unreachable -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() })
else -> tokenUM.tokenItemState
},
isQuickActionsShown = isQuickActionsShown,
),
lastInList = true,
)
}
}
private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider<PortfolioTokenUM>(
collection = listOf(
tokenUM.copy(
tokenItemState = (tokenUM.tokenItemState as TokenItemState.Content).copy(
fiatAmountState = contentFiatAmount?.copy(
icons = persistentListOf(
TokenFiatAmountState.Content.IconUM(
iconRes = R.drawable.ic_staking_24,
tint = IconTint.Accent,
),
),
),
),
),
tokenUM.copy(
tokenItemState = tokenUM.tokenItemState.copy(
fiatAmountState = contentFiatAmount?.copy(text = DASH_SIGN),
subtitle2State = (tokenUM.tokenItemState.subtitle2State as? TokenItemState.Subtitle2State.TextContent)
?.copy(text = DASH_SIGN),
),
),
tokenUM.copy(isBalanceHidden = true),
tokenUM.copy(
tokenItemState = TokenItemState.Unreachable(
id = tokenUM.tokenItemState.id,
iconState = tokenUM.tokenItemState.iconState,
titleState = tokenUM.tokenItemState.titleState,
subtitleState = tokenUM.tokenItemState.subtitleState,
onItemClick = {},
onItemLongClick = {},
),
),
tokenUM.copy(
tokenItemState = TokenItemState.NoAddress(
id = tokenUM.tokenItemState.id,
iconState = tokenUM.tokenItemState.iconState,
titleState = tokenUM.tokenItemState.titleState,
subtitleState = tokenUM.tokenItemState.subtitleState,
onItemLongClick = {},
),
),
tokenUM.copy(
tokenItemState = TokenItemState.Loading(
id = tokenUM.tokenItemState.id,
iconState = tokenUM.tokenItemState.iconState,
titleState = tokenUM.tokenItemState.titleState as TokenItemState.TitleState.Content,
subtitleState = tokenUM.tokenItemState.subtitleState,
),
),
),
) {
companion object {
val tokenUM = PreviewMyPortfolioUMProvider().sampleToken
val contentFiatAmount = tokenUM.tokenItemState.fiatAmountState as? TokenFiatAmountState.Content
}
}

View file

@ -0,0 +1,268 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun PortfolioQuickActions(
actions: ImmutableList<QuickActionUM>,
isVisible: Boolean,
onActionClick: (QuickActionUM) -> Unit,
onActionLongClick: (QuickActionUM) -> Unit,
modifier: Modifier = Modifier,
) {
if (actions.isEmpty()) return
AnimatedVisibility(
modifier = modifier,
visible = isVisible,
enter = expandVertically(expandFrom = Alignment.Top),
exit = shrinkVertically(shrinkTowards = Alignment.Top),
) {
Column {
actions.fastForEach { action ->
LineSeparator()
QuickActionItem(
state = action,
onClick = { onActionClick(action) },
onLongClick = { onActionLongClick(action) }.takeIf { action.isLongClickAvailable },
)
}
}
}
}
@Composable
private fun AnimatedVisibilityScope.LineSeparator(modifier: Modifier = Modifier) {
val lineColor = TangemTheme.colors.stroke.primary
val strokeWidth = TangemTheme.dimens.size1
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
val startPadding = TangemTheme.dimens.spacing30
val height = TangemTheme.dimens.size16
Canvas(
modifier = modifier
.animateEnterExit(
enter = expandVertically(
animationSpec = spring(
stiffness = Spring.StiffnessLow,
),
expandFrom = Alignment.Top,
) + fadeIn(),
exit = shrinkVertically(
spring(
stiffness = Spring.StiffnessLow,
),
shrinkTowards = Alignment.Top,
) + fadeOut(),
)
.fillMaxWidth()
.height(height),
) {
val x = if (isLtr) startPadding.toPx() else size.width - startPadding.toPx()
drawLine(
color = lineColor,
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = strokeWidth.toPx(),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AnimatedVisibilityScope.QuickActionItem(
state: QuickActionUM,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
val onLongClickInternal: (() -> Unit)? = if (onLongClick != null) {
{
hapticManager.perform(TangemHapticEffect.View.LongPress)
onLongClick()
}
} else {
null
}
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onLongClick = onLongClickInternal,
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onClick()
},
)
.padding(horizontal = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing4),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing18),
) {
QuickActionIcon(state)
Column(
modifier = Modifier
.animateEnterExit(
enter = fadeIn(),
exit = fadeOut(),
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) {
val containerColor = TangemTheme.colors.background.action
Box(
Modifier
.animateEnterExit(
enter = scaleIn(),
exit = scaleOut(),
)
.background(
color = TangemTheme.colors.button.secondary,
shape = CircleShape,
)
.size(TangemTheme.dimens.size32)
.drawWithContent {
drawContent()
if (state is QuickActionUM.Exchange && state.shouldShowBadge) {
drawBadge(containerColor = containerColor, offset = 4.dp)
}
},
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier
.requiredSize(TangemTheme.dimens.size16),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors.button.primary,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
var isVisible by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxWidth()
.height(680.dp),
) {
Button(
onClick = { isVisible = !isVisible },
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
) {
Text(text = "Toggle")
}
SpacerH4()
Box(
modifier = Modifier.background(color = TangemTheme.colors.background.action),
) {
PortfolioQuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),
QuickActionUM.Receive,
),
isVisible = isVisible,
onActionClick = {},
onActionLongClick = {},
)
}
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewRtl() {
TangemThemePreview(rtl = true) {
Box(modifier = Modifier.background(color = TangemTheme.colors.background.action)) {
PortfolioQuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),
QuickActionUM.Receive,
),
isVisible = true,
onActionClick = {},
onActionLongClick = {},
)
}
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
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.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SimpleSettingsRow
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.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.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import kotlinx.collections.immutable.toImmutableList
@Composable
fun TokenActionsBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<TokenActionsBSContentUM>(
config = config,
title = { content ->
TangemBottomSheetTitle(content.title)
},
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: TokenActionsBSContentUM) {
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
content.actions.forEachIndexed { index, action ->
Box(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = content.actions.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action),
) {
SimpleSettingsRow(
title = action.text.resolveReference(),
icon = action.iconRes,
redesign = true,
onItemsClick = { content.onActionClick(action) },
)
}
}
}
}
@Preview(widthDp = 360, heightDp = 640)
@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview(
alwaysShowBottomSheets = true,
) {
Box(Modifier.background(TangemTheme.colors.background.secondary)) {
TokenActionsBottomSheet(
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = TokenActionsBSContentUM(
title = "Wallet 1",
actions = TokenActionsBSContentUM.Action.entries.toImmutableList(),
onActionClick = {},
),
),
)
}
}
}

View file

@ -0,0 +1,145 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
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.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM
import com.tangem.features.feed.impl.R
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun WalletSelectorBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<WalletSelectorBSContentUM>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
addBottomInsets = false,
title = { content ->
TangemTopAppBar(
title = resourceReference(R.string.common_choose_wallet),
titleAlignment = Alignment.CenterHorizontally,
startButton = TopAppBarButtonUM.Back(content.onBack),
height = TangemTopAppBarHeight.BOTTOM_SHEET,
)
},
) { content ->
Content(
modifier = Modifier
.fillMaxSize()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing8,
),
state = content,
)
}
}
@Composable
private fun Content(state: WalletSelectorBSContentUM, modifier: Modifier = Modifier) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
) {
BlockCard(
modifier = Modifier.fillMaxSize(),
colors = TangemBlockCardColors.copy(
containerColor = TangemTheme.colors.background.action,
disabledContainerColor = TangemTheme.colors.background.action,
),
) {
state.userWallets.forEach { state ->
key(state.id) {
UserWalletItem(
modifier = Modifier.fillMaxWidth(),
blockColors = TangemBlockCardColors.copy(
containerColor = TangemTheme.colors.background.action,
disabledContainerColor = TangemTheme.colors.background.action,
),
state = state,
)
}
}
}
SpacerH(bottomBarHeight)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
WalletSelectorBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = WalletSelectorBSContentUM(
userWallets = persistentListOf(
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.None,
),
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.Checkmark,
),
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.None,
),
),
onBack = {},
),
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewContent() {
TangemThemePreview {
Content(
state = WalletSelectorBSContentUM(
userWallets = persistentListOf(
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.None,
),
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.Checkmark,
),
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.None,
),
),
onBack = {},
),
)
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM
import com.tangem.features.feed.impl.R
import kotlinx.collections.immutable.persistentListOf
internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider<AddToPortfolioBSContentUM> {
private val blockchainRow = BlockchainRowUM(
id = "1",
name = "Etherium 3",
type = "TEST",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = false,
isSelected = false,
)
val userWallet = UserWalletItemUM(
id = "1",
name = stringReference("Wallet 1"),
information = UserWalletItemUM.Information.Loaded(TextReference.Str("3 cards")),
balance = UserWalletItemUM.Balance.Loading,
isEnabled = true,
endIcon = UserWalletItemUM.EndIcon.Arrow,
onClick = {},
)
override val values: Sequence<AddToPortfolioBSContentUM>
get() = sequenceOf(
AddToPortfolioBSContentUM(
selectedWallet = userWallet,
selectNetworkUM = SelectNetworkUM(
tokenId = "etherium",
tokenName = "Etherium",
tokenCurrencySymbol = "ETH",
networks = persistentListOf(
blockchainRow.copy(
type = "MAIN",
isMainNetwork = true,
isSelected = true,
),
blockchainRow,
blockchainRow,
),
onNetworkSwitchClick = { _, _ -> },
iconUrl = null,
),
isScanCardNotificationVisible = true,
isWalletBlockVisible = true,
isContinueButtonEnabled = true,
onContinueButtonClick = {},
walletSelectorConfig = TangemBottomSheetConfig.Empty,
),
AddToPortfolioBSContentUM(
selectedWallet = userWallet,
selectNetworkUM = SelectNetworkUM(
tokenId = "etherium",
tokenName = "Etherium Etherium Etherium Etherium",
tokenCurrencySymbol = "ETH",
networks = persistentListOf(
blockchainRow.copy(
type = "MAIN",
isMainNetwork = true,
isSelected = true,
).copy(name = "Etherium Etherium Etherium Etherium"),
*Array(25) { blockchainRow },
),
onNetworkSwitchClick = { _, _ -> },
iconUrl = null,
),
isScanCardNotificationVisible = true,
isWalletBlockVisible = false,
isContinueButtonEnabled = false,
onContinueButtonClick = {},
walletSelectorConfig = TangemBottomSheetConfig.Empty,
),
)
}

View file

@ -0,0 +1,150 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.*
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@Suppress("PropertyUsedBeforeDeclaration")
internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider<MyPortfolioUM> {
override val values: Sequence<MyPortfolioUM>
get() = sequenceOf(
MyPortfolioUM.Tokens(
tokens = persistentListOf(sampleToken, sampleToken),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Available,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
tokenReceiveBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.Tokens(
tokens = persistentListOf(sampleToken, sampleToken.copy(isQuickActionsShown = true)),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Unavailable,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
tokenReceiveBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.Tokens(
tokens = persistentListOf(sampleToken.copy(isQuickActionsShown = true), sampleToken),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Loading,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
tokenReceiveBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.AddFirstToken(
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.Content(
items = persistentListOf(
walletPortfolioHeader,
accountToken,
accountToken,
),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Available,
onAddClick = {},
),
MyPortfolioUM.Content(
items = persistentListOf(
walletHeader,
accountHeader,
accountToken,
accountToken,
),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Available,
onAddClick = {},
),
MyPortfolioUM.Content(
items = persistentListOf(
walletHeader,
accountHeader,
accountToken.copy(isQuickActionsShown = true),
accountToken,
),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Available,
onAddClick = {},
),
MyPortfolioUM.Loading,
MyPortfolioUM.Unavailable,
MyPortfolioUM.UnavailableForWallet,
)
val walletHeader
get() = WalletHeader(
id = UUID.randomUUID().toString(),
name = stringReference("Wallet 1"),
)
val walletPortfolioHeader
get() = PortfolioHeader(
state = AccountTitleUM.Text(title = stringReference("Wallet 1")),
id = UUID.randomUUID().toString(),
)
val accountHeader
get() = PortfolioHeader(
state = AccountTitleUM.Account(
icon = AccountIconPreviewData.randomAccountIcon(),
name = stringReference("Main Account"),
prefixText = TextReference.EMPTY,
),
id = UUID.randomUUID().toString(),
)
val coinIconState
get() = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22,
isGrayscale = false,
shouldShowCustomBadge = false,
)
val accountToken
get() = sampleToken.copy(
tokenItemState = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = coinIconState,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")),
fiatAmountState = FiatAmountState.Content(text = "321 $"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"),
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(value = "Token")),
onItemClick = {},
onItemLongClick = {},
),
)
val sampleToken
get() = PortfolioTokenUM(
tokenItemState = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "My wallet")),
fiatAmountState = FiatAmountState.Content(text = "486,65 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference(value = "XRP Ledger token"),
),
onItemClick = {},
onItemLongClick = {},
),
isQuickActionsShown = false,
quickActions = PortfolioTokenUM.QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),
QuickActionUM.Receive,
),
onQuickActionClick = {},
onQuickActionLongClick = {},
),
isBalanceHidden = false,
walletId = UserWalletId(""),
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
internal data class AddToPortfolioBSContentUM(
val selectedWallet: UserWalletItemUM,
val selectNetworkUM: SelectNetworkUM,
val isWalletBlockVisible: Boolean,
val isScanCardNotificationVisible: Boolean,
val isContinueButtonEnabled: Boolean,
val onContinueButtonClick: () -> Unit,
val walletSelectorConfig: TangemBottomSheetConfig,
) : TangemBottomSheetConfigContent

View file

@ -0,0 +1,52 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class MyPortfolioUM {
abstract val addToPortfolioBSConfig: TangemBottomSheetConfig?
data class Tokens(
override val addToPortfolioBSConfig: TangemBottomSheetConfig,
val tokens: ImmutableList<PortfolioTokenUM>,
val buttonState: AddButtonState,
val tokenReceiveBSConfig: TangemBottomSheetConfig,
val onAddClick: () -> Unit,
) : MyPortfolioUM() {
enum class AddButtonState {
Loading,
Available,
Unavailable,
}
}
data class Content(
val items: ImmutableList<PortfolioListItem>,
val buttonState: Tokens.AddButtonState,
val onAddClick: () -> Unit,
) : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty
}
data class AddFirstToken(
override val addToPortfolioBSConfig: TangemBottomSheetConfig,
val onAddClick: () -> Unit,
) : MyPortfolioUM()
data object Loading : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null
}
data object Unavailable : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null
}
data object UnavailableForWallet : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed interface PortfolioListItem {
val id: String
}
internal data class WalletHeader(
override val id: String,
val name: TextReference,
) : PortfolioListItem
internal data class PortfolioHeader(
override val id: String,
val state: AccountTitleUM,
) : PortfolioListItem
internal data class PortfolioTokenUM(
val tokenItemState: TokenItemState,
val walletId: UserWalletId,
val isBalanceHidden: Boolean,
val isQuickActionsShown: Boolean,
val quickActions: QuickActions,
) : PortfolioListItem {
override val id: String = tokenItemState.id
data class QuickActions(
val actions: ImmutableList<QuickActionUM>,
val onQuickActionClick: (QuickActionUM) -> Unit,
val onQuickActionLongClick: (QuickActionUM) -> Unit,
)
}

View file

@ -0,0 +1,51 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.feed.impl.R
@Immutable
internal sealed class QuickActionUM(
val title: TextReference,
val description: TextReference,
@DrawableRes val icon: Int,
val isLongClickAvailable: Boolean = false,
) {
data object Buy : QuickActionUM(
title = resourceReference(R.string.common_buy),
description = resourceReference(R.string.buy_token_description),
icon = R.drawable.ic_plus_24,
)
data class Exchange(
val shouldShowBadge: Boolean,
) : QuickActionUM(
title = resourceReference(R.string.common_exchange),
description = resourceReference(R.string.exсhange_token_description),
icon = R.drawable.ic_exchange_vertical_24,
)
data object Receive : QuickActionUM(
title = resourceReference(R.string.common_receive),
description = resourceReference(R.string.receive_token_description),
icon = R.drawable.ic_arrow_down_24,
isLongClickAvailable = true,
)
data object Stake : QuickActionUM(
title = resourceReference(R.string.common_stake),
description = resourceReference(R.string.stake_token_description),
icon = R.drawable.ic_staking_24,
)
data class YieldMode(
private val apy: String,
) : QuickActionUM(
title = resourceReference(R.string.common_yield_mode),
description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)),
icon = R.drawable.ic_analytics_up_mini_24,
)
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import kotlinx.collections.immutable.ImmutableList
internal data class SelectNetworkUM(
val tokenId: String,
val iconUrl: String?,
val tokenName: String,
val tokenCurrencySymbol: String,
val networks: ImmutableList<BlockchainRowUM>,
val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit,
)

View file

@ -0,0 +1,58 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.feed.impl.R
import kotlinx.collections.immutable.ImmutableList
internal data class TokenActionsBSContentUM(
val title: String,
val actions: ImmutableList<Action>,
val onActionClick: (Action) -> Unit,
) : TangemBottomSheetConfigContent {
@Immutable
enum class Action(
val text: TextReference,
@DrawableRes val iconRes: Int,
) {
CopyAddress(
text = resourceReference(R.string.common_copy_address),
iconRes = R.drawable.ic_copy_24,
),
Send(
text = resourceReference(R.string.common_send),
iconRes = R.drawable.ic_arrow_up_24,
),
Receive(
text = resourceReference(R.string.common_receive),
iconRes = R.drawable.ic_arrow_down_24,
),
Buy(
text = resourceReference(R.string.common_buy),
iconRes = R.drawable.ic_plus_24,
),
Sell(
text = resourceReference(R.string.common_sell),
iconRes = R.drawable.ic_currency_24,
),
Exchange(
text = resourceReference(R.string.common_exchange),
iconRes = R.drawable.ic_exchange_horizontal_24,
),
Stake(
text = resourceReference(R.string.common_stake),
iconRes = R.drawable.ic_staking_24,
),
YieldMode(
text = resourceReference(R.string.common_yield_mode),
iconRes = R.drawable.ic_analytics_up_mini_24,
),
;
val order: Int = ordinal
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import kotlinx.collections.immutable.ImmutableList
internal data class WalletSelectorBSContentUM(
val userWallets: ImmutableList<UserWalletItemUM>,
val onBack: () -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -1,27 +1,69 @@
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.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.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 kotlinx.serialization.Serializable
@Suppress("UnusedPrivateProperty") // TODO will be remove in next PR
internal class DefaultMarketsTokenListComponent(
appComponentContext: AppComponentContext,
private val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit)? = null,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val params: Params,
) : 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() {
}
@Serializable
data class Params(
val onBackClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val preselectedSortType: SortByTypeUM,
val shouldAlwaysShowSearchBar: Boolean,
)
}

View file

@ -1,23 +1,65 @@
package com.tangem.features.feed.components.news.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.R
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
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.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.ui.news.details.NewsDetailsContent
import kotlinx.serialization.Serializable
internal class DefaultNewsDetailsComponent(
appComponentContext: AppComponentContext,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val params: Params,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val newsDetailsModel = getOrCreateModel<NewsDetailsModel, Params>(params = params)
@Composable
override fun Title() {
override fun Title(bottomSheetState: State<BottomSheetState>) {
val background = LocalMainBottomSheetColor.current.value
val state by newsDetailsModel.state.collectAsStateWithLifecycle()
TangemTopAppBar(
containerColor = background,
title = null,
startButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_back_24,
onClicked = state.onBackClick,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
),
endButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_share_24,
onClicked = state.onShareClick,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
),
)
}
@Composable
override fun Content(modifier: Modifier) {
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
val state by newsDetailsModel.state.collectAsStateWithLifecycle()
NewsDetailsContent(
state = state,
modifier = modifier,
)
}
@Composable
override fun Footer() {
}
@Serializable
data class Params(
val articleId: Int,
val onBackClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val preselectedArticlesId: List<Int> = emptyList(),
)
}

View file

@ -1,23 +1,57 @@
package com.tangem.features.feed.components.news.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.R
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.features.feed.model.news.list.NewsListModel
import com.tangem.features.feed.ui.news.list.NewsListContent
import kotlinx.serialization.Serializable
internal class DefaultNewsListComponent(
appComponentContext: AppComponentContext,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val params: Params,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val newsListModel = getOrCreateModel<NewsListModel, Params>(params = params)
@Composable
override fun Title() {
override fun Title(bottomSheetState: State<BottomSheetState>) {
val background = LocalMainBottomSheetColor.current.value
val state by newsListModel.state.collectAsStateWithLifecycle()
TangemTopAppBar(
containerColor = background,
title = stringResourceSafe(R.string.common_news),
startButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_back_24,
onClicked = state.onBackClick,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
),
)
}
@Composable
override fun Content(modifier: Modifier) {
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
val state by newsListModel.state.collectAsStateWithLifecycle()
NewsListContent(
state = state,
modifier = modifier,
)
}
@Composable
override fun Footer() {
}
@Serializable
data class Params(
val onArticleClicked: (currentArticle: Int, prefetchedArticles: List<Int>) -> Unit,
val onBackClick: () -> Unit,
)
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.feed.deeplink
import android.net.Uri
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
internal class DefaultNewsDetailsDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@Assisted private val deeplinkUri: Uri,
private val appRouter: AppRouter,
) : NewsDetailsDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
scope.launch {
val articleId = extractArticleIdFromUri(deeplinkUri)
if (articleId == null) {
Timber.e(
"""
Failed to extract article ID from deep link
|- Received URI: $deeplinkUri
""".trimIndent(),
)
return@launch
}
appRouter.push(
AppRoute.NewsDetails(newsId = articleId),
)
}
}
/**
* Parsing URI with format https://tangem.com/news/{category}/{id}-{slug} to get id
*/
private fun extractArticleIdFromUri(uri: Uri): Int? {
val path = uri.path ?: return null
val pathSegments = path.split("/").filter { it.isNotBlank() }
if (pathSegments.isEmpty() || pathSegments[0] != "news" || pathSegments.size < 2) {
return null
}
val lastSegment = pathSegments.last()
val idPart = lastSegment.substringBefore("-")
return idPart.toIntOrNull()
}
@AssistedFactory
interface Factory : NewsDetailsDeepLinkHandler.Factory {
override fun create(coroutineScope: CoroutineScope, deeplinkUri: Uri): DefaultNewsDetailsDeepLinkHandler
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.feed.deeplink.di
import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface FeedDeepLinkModule {
@Binds
@Singleton
fun bindNewsDetailsDeepLinkHandlerFactory(
impl: DefaultNewsDetailsDeepLinkHandler.Factory,
): NewsDetailsDeepLinkHandler.Factory
}

View file

@ -3,6 +3,10 @@ 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 com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.model.news.list.NewsListModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -17,4 +21,24 @@ internal interface ModelModule {
@IntoMap
@ClassKey(FeedComponentModel::class)
fun bindsFeedComponentModel(model: FeedComponentModel): Model
@Binds
@IntoMap
@ClassKey(MarketsListModel::class)
fun provideMarketsListModel(model: MarketsListModel): Model
@Binds
@IntoMap
@ClassKey(MarketsTokenDetailsModel::class)
fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model
@Binds
@IntoMap
@ClassKey(NewsDetailsModel::class)
fun provideNewsDetailsModel(model: NewsDetailsModel): Model
@Binds
@IntoMap
@ClassKey(NewsListModel::class)
fun provideNewsListModel(model: NewsListModel): Model
}

View file

@ -0,0 +1,98 @@
package com.tangem.features.feed.model.converter
import com.tangem.pagination.Batch
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
internal class BatchListStateManager<Key, Domain, UI>(
private val converter: BatchItemConverter<Domain, UI>,
private val dispatchers: CoroutineDispatcherProvider,
) {
val state = MutableStateFlow(BatchListState<Key, Domain, UI>())
suspend fun update(newList: List<Batch<Key, List<Domain>>>, forceUpdate: Boolean) =
withContext(dispatchers.default) {
state.update { currentState ->
val uiBatches = currentState.uiBatches
val previousList = currentState.processedItems
if (newList.isEmpty()) {
return@update BatchListState(uiBatches = emptyList(), processedItems = emptyList())
}
val isInitialLoading = forceUpdate ||
previousList.isNullOrEmpty() ||
newList.firstOrNull()?.key != previousList.firstOrNull()?.key
val outItems = if (isInitialLoading) {
newList.map { batch ->
Batch(key = batch.key, data = batch.data.map { converter.convert(it) })
}
} else {
if (previousList.size != newList.size) {
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = newList.filter { keysToAdd.contains(it.key) }
uiBatches + newBatches.map { batch ->
Batch(key = batch.key, data = batch.data.map { converter.convert(it) })
}
} else {
uiBatches.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (prevBatch == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, currentUiItem ->
val prevItem = prevBatch.data.getOrNull(index)
val newItem = newBatch.data.getOrNull(index)
if (prevItem != null && newItem != null) {
converter.update(prevItem, currentUiItem, newItem)
} else if (newItem != null) {
converter.convert(newItem)
} else {
currentUiItem
}
},
)
}
}
}
coroutineContext.ensureActive()
BatchListState(
uiBatches = outItems,
processedItems = newList,
)
}
}
}
internal data class BatchListState<K, Domain, UI>(
val uiBatches: List<Batch<K, List<UI>>> = emptyList(),
val processedItems: List<Batch<K, List<Domain>>>? = emptyList(),
)
internal interface BatchItemConverter<Domain, UI> {
fun convert(item: Domain): UI
fun update(prevDomain: Domain, currentUI: UI, newDomain: Domain): UI {
return convert(newDomain)
}
}
internal fun <K, T> Flow<List<Batch<K, List<T>>>>.distinctBatchesContent(): Flow<List<Batch<K, List<T>>>> {
return this.distinctUntilChanged { old, new ->
old.size == new.size &&
old.map { it.key } == new.map { it.key } &&
old.flatMap { it.data } == new.flatMap { it.data }
}
}

View file

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

View file

@ -0,0 +1,52 @@
package com.tangem.features.feed.model.converter
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.news.ShortArticle
import com.tangem.features.feed.ui.utils.mapFormattedDate
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
internal class ShortArticleToArticleConfigUMConverter(
private val isTrending: Provider<Boolean>,
) : Converter<List<ShortArticle>, ImmutableList<ArticleConfigUM>> {
override fun convert(value: List<ShortArticle>): ImmutableList<ArticleConfigUM> {
return value.map { shortArticle ->
ArticleConfigUM(
id = shortArticle.id,
title = shortArticle.title,
score = shortArticle.score,
isTrending = isTrending(),
tags = buildArticleTags(shortArticle),
createdAt = mapFormattedDate(shortArticle.createdAt),
isViewed = shortArticle.viewed,
)
}.toPersistentList()
}
private fun buildArticleTags(article: ShortArticle): ImmutableSet<LabelUM> {
val categoryLabels = article.categories.map { category ->
LabelUM(text = TextReference.Str(category.name))
}
val tokenLabels = article.relatedTokens.map { token ->
LabelUM(
text = TextReference.Str(token.symbol),
leadingContent = LabelLeadingContentUM.Token(
iconUrl = getTokenIconUrlFromDefaultHost(
tokenId = CryptoCurrency.RawID(token.id),
),
),
)
}
return (categoryLabels + tokenLabels).toPersistentSet()
}
}

View file

@ -5,7 +5,6 @@ import arrow.core.getOrElse
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.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -17,8 +16,10 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.feed.state.FeedMarketsBatchFlowManager
import com.tangem.features.feed.model.feed.state.TrendingNewsStateFactory
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
@ -31,6 +32,8 @@ import kotlinx.coroutines.launch
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import javax.inject.Inject
import kotlin.collections.all
import kotlin.collections.map
@Stable
@ModelScoped
@ -65,17 +68,15 @@ internal class FeedComponentModel @Inject constructor(
internal val state: StateFlow<FeedListUM>
field = MutableStateFlow<FeedListUM>(initialState())
private val searchBarStateFactory by lazy(LazyThreadSafetyMode.NONE) {
SearchBarStateFactory(
currentStateProvider = Provider { state.value },
onStateUpdate = { newState -> state.update { newState } },
)
}
private val trendingNewsStateFactory by lazy(LazyThreadSafetyMode.NONE) {
TrendingNewsStateFactory(
currentStateProvider = Provider { state.value },
onStateUpdate = { newState -> state.update { newState } },
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
}
},
)
}
@ -96,14 +97,7 @@ internal class FeedComponentModel @Inject constructor(
flow4 = manageTrendingNewsUseCase.observeTrendingNews(),
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult ->
updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder)
trendingNewsStateFactory.updateTrendingNewsState(
result = trendingNewsResult,
onRetryClicked = {
modelScope.launch(dispatchers.default) {
fetchTrendingNewsUseCase.invoke()
}
},
)
trendingNewsStateFactory.updateTrendingNewsState(result = trendingNewsResult)
updateGlobalState()
val currentSortType = state.value.marketChartConfig.currentSortByType
val items = itemsByOrder[currentSortType]
@ -136,12 +130,9 @@ internal class FeedComponentModel @Inject constructor(
private fun initialState(): FeedListUM {
return FeedListUM(
currentDate = getCurrentDate(),
searchBar = SearchBarUM(
feedListSearchBar = FeedListSearchBar(
placeholderText = resourceReference(R.string.markets_search_header_title),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = { },
onBarClick = { params.feedClickIntents.onMarketOpenClick(null) },
),
feedListCallbacks = FeedListCallbacks(
onSearchClick = {},
@ -151,7 +142,11 @@ internal class FeedComponentModel @Inject constructor(
onMarketItemClick = {},
onSortTypeClick = {},
),
news = NewsUM.Loading,
news = NewsUM(
content = persistentListOf(),
onRetryClicked = {},
newsUMState = NewsUMState.LOADING,
),
trendingArticle = null,
marketChartConfig = MarketChartConfig(
marketCharts = buildMap {
@ -235,10 +230,10 @@ internal class FeedComponentModel @Inject constructor(
val newsState = currentState.news
val marketCharts = currentState.marketChartConfig.marketCharts
val isNewsLoading = newsState is NewsUM.Loading
val isNewsLoading = newsState.newsUMState == NewsUMState.LOADING
val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading }
val isNewsError = newsState is NewsUM.Error
val isNewsError = newsState.newsUMState == NewsUMState.ERROR
val areAllChartsError = marketCharts.values.all { it is MarketChartUM.LoadingError }
val newGlobalState = when {
@ -304,7 +299,6 @@ internal class FeedComponentModel @Inject constructor(
private fun updateCallbacks() {
state.update { feedListUM ->
feedListUM.copy(
searchBar = state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange),
feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::onSortTypeClick,
onMarketItemClick = { item ->
@ -320,7 +314,17 @@ internal class FeedComponentModel @Inject constructor(
params.feedClickIntents.onMarketOpenClick(sortBy)
},
onArticleClick = { articleId ->
params.feedClickIntents.onArticleClick(articleId)
val trendingArticleId = state.value.trendingArticle?.id
params.feedClickIntents.onArticleClick(
articleId = articleId,
preselectedArticlesId = listOfNotNull(trendingArticleId) +
when (state.value.news.newsUMState) {
NewsUMState.CONTENT -> state.value.news.content.map { it.id }
NewsUMState.LOADING,
NewsUMState.ERROR,
-> emptyList()
},
)
},
onOpenAllNews = {
params.feedClickIntents.onOpenAllNews()

View file

@ -2,14 +2,14 @@ package com.tangem.features.feed.model.feed
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
/**
* Callback interface for feed model navigation actions.
*/
internal interface FeedModelClickIntents {
fun onMarketItemClick(token: TokenMarketParams, appCurrency: AppCurrency)
fun onMarketOpenClick(sortBy: SortByTypeUM)
fun onArticleClick(articleId: Int)
fun onMarketOpenClick(sortBy: SortByTypeUM?)
fun onArticleClick(articleId: Int, preselectedArticlesId: List<Int> = emptyList())
fun onOpenAllNews()
}

View file

@ -1,13 +1,12 @@
package com.tangem.features.feed.ui.feed.state
package com.tangem.features.feed.model.feed.state
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.model.converter.MarketsTokenItemConverter
import com.tangem.features.feed.ui.market.state.MarketsListUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.pagination.Batch
import com.tangem.features.feed.model.converter.*
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
@ -18,8 +17,11 @@ import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class FeedMarketsBatchFlowManager(
@ -44,7 +46,7 @@ internal class FeedMarketsBatchFlowManager(
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
started = Eagerly,
initialValue = emptyMap(),
)
@ -60,7 +62,7 @@ internal class FeedMarketsBatchFlowManager(
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
started = Eagerly,
initialValue = emptyMap(),
)
@ -76,7 +78,7 @@ internal class FeedMarketsBatchFlowManager(
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
started = Eagerly,
initialValue = emptyMap(),
)
@ -141,11 +143,29 @@ internal class FeedMarketsBatchFlowManager(
private val dispatchers: CoroutineDispatcherProvider,
) {
private val updateStateJob = JobHolder()
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = resultBatches.map { it.uiBatches }
private val batchConverter = object : BatchItemConverter<TokenMarket, MarketsListItemUM> {
private val internalConverter: MarketsTokenItemConverter
get() = MarketsTokenItemConverter(
currentTrendInterval = MarketsListUM.TrendInterval.H24,
appCurrency = currentAppCurrency(),
)
override fun convert(item: TokenMarket) = internalConverter.convert(item)
override fun update(prevDomain: TokenMarket, currentUI: MarketsListItemUM, newDomain: TokenMarket) =
internalConverter.update(prevDomain, currentUI, newDomain)
}
private val stateManager = BatchListStateManager<Int, TokenMarket, MarketsListItemUM>(
converter = batchConverter,
dispatchers = dispatchers,
)
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>> =
uiBatches
stateManager.state
.map { it.uiBatches }
.map { batches ->
batches.asSequence()
.map { it.data }
@ -155,7 +175,7 @@ internal class FeedMarketsBatchFlowManager(
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
started = Eagerly,
initialValue = persistentListOf(),
)
@ -170,7 +190,7 @@ internal class FeedMarketsBatchFlowManager(
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
started = Eagerly,
initialValue = false,
)
@ -185,7 +205,7 @@ internal class FeedMarketsBatchFlowManager(
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
started = Eagerly,
initialValue = false,
)
@ -210,15 +230,11 @@ internal class FeedMarketsBatchFlowManager(
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size &&
a.map { it.key } == b.map { it.key } &&
a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEach {
.distinctBatchesContent()
.onEach { newList ->
coroutineScope {
launch {
updateState(it)
stateManager.update(newList = newList, forceUpdate = false)
}.saveIn(updateStateJob)
}
}
@ -226,77 +242,9 @@ internal class FeedMarketsBatchFlowManager(
.launchIn(modelScope)
}
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
withContext(dispatchers.default) {
resultBatches.update { resultBatches ->
val items = resultBatches.uiBatches
val previousList = resultBatches.processedItems
val converter = MarketsTokenItemConverter(
currentTrendInterval = MarketsListUM.TrendInterval.H24,
appCurrency = currentAppCurrency(),
)
if (newList.isEmpty()) {
return@update ResultBatches(processedItems = emptyList())
}
val isInitialLoading =
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
val outItems = if (isInitialLoading) {
newList.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
// As nextBatchSize = 0, we only have one batch, but keep the logic for safety
if (previousList.size != newList.size) {
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = newList.filter { keysToAdd.contains(it.key) }
items + newBatches.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (prevBatch == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data.getOrNull(index)
val newItem = newBatch.data.getOrNull(index)
if (prevItem != null && newItem != null) {
converter.update(prevItem, marketsListItemUM, newItem)
} else {
newItem?.let { converter.convert(it) } ?: marketsListItemUM
}
},
)
}
}
}
currentCoroutineContext().ensureActive()
ResultBatches(
uiBatches = outItems,
processedItems = newList,
)
}
}
fun reload(fiatPriceCurrency: String) {
modelScope.launch(dispatchers.default) {
resultBatches.value = ResultBatches()
stateManager.state.value = BatchListState()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
@ -366,16 +314,11 @@ internal class FeedMarketsBatchFlowManager(
}
fun getTokenMarketById(tokenId: CryptoCurrency.RawID): TokenMarket? {
return resultBatches.value.processedItems
return stateManager.state.value.processedItems
?.asSequence()
?.flatMap { it.data }
?.firstOrNull { it.id == tokenId }
}
private data class ResultBatches(
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
}
private fun TokenMarketListConfig.Order.toSortByTypeUM(): SortByTypeUM {

View file

@ -0,0 +1,83 @@
package com.tangem.features.feed.model.feed.state
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.model.converter.ShortArticleToArticleConfigUMConverter
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.NewsUM
import com.tangem.features.feed.ui.feed.state.NewsUMState
import com.tangem.utils.Provider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
internal class TrendingNewsStateFactory(
private val currentStateProvider: Provider<FeedListUM>,
private val onStateUpdate: (FeedListUM) -> Unit,
private val onRetryClicked: () -> Unit,
) {
fun updateTrendingNewsState(result: TrendingNews) {
val currentState = currentStateProvider()
when (result) {
is TrendingNews.Data -> handleDataState(currentState, result.articles)
is TrendingNews.Error -> handleErrorState(currentState)
}
}
private fun handleDataState(currentState: FeedListUM, articles: List<ShortArticle>) {
val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles)
val commonArticlesUM = getShortArticleConfigConverter(isTrending = false)
.convert(commonArticles)
.toPersistentList()
val updatedNews = when (currentState.news.newsUMState) {
NewsUMState.CONTENT -> currentState.news.copy(content = commonArticlesUM)
NewsUMState.LOADING,
NewsUMState.ERROR,
-> NewsUM(
content = commonArticlesUM,
onRetryClicked = onRetryClicked,
newsUMState = NewsUMState.CONTENT,
)
}
onStateUpdate(
currentState.copy(
trendingArticle = trendingArticle?.let { article ->
getShortArticleConfigConverter(isTrending = true)
.convert(listOf(article))
}?.firstOrNull(),
news = updatedNews,
),
)
}
private fun handleErrorState(currentState: FeedListUM) {
onStateUpdate(
currentState.copy(
trendingArticle = null,
news = NewsUM(
content = persistentListOf(),
onRetryClicked = onRetryClicked,
newsUMState = NewsUMState.ERROR,
),
),
)
}
private fun separateTrendingAndCommonArticles(
articles: List<ShortArticle>,
): Pair<ShortArticle?, List<ShortArticle>> {
val trendingArticleIndex = articles.indexOfFirst { it.isTrending }
return if (trendingArticleIndex != -1) {
val trendingArticle = articles[trendingArticleIndex]
val commonArticles = articles.toMutableList().apply { removeAt(trendingArticleIndex) }
trendingArticle to commonArticles
} else {
null to articles
}
}
private fun getShortArticleConfigConverter(isTrending: Boolean): ShortArticleToArticleConfigUMConverter {
return ShortArticleToArticleConfigUMConverter(isTrending = Provider { isTrending })
}
}

View file

@ -0,0 +1,696 @@
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.news.model.NewsListConfig
import com.tangem.domain.news.usecase.GetNewsUseCase
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.model.converter.ShortArticleToArticleConfigUMConverter
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.persistentListOf
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,
private val getNewsUseCase: GetNewsUseCase,
) : 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 shortArticleToArticleConfigUMConverter by lazy {
ShortArticleToArticleConfigUMConverter(isTrending = Provider { false })
}
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,
relatedNews = MarketsTokenDetailsUM.RelatedNews(
articles = persistentListOf(),
onArticledClicked = {},
),
),
)
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()
loadRelatedNews()
}
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 loadRelatedNews() {
modelScope.launch(dispatchers.default) {
getNewsUseCase.getNews(
limit = RELATED_NEWS_LIMIT,
newsListConfig = NewsListConfig(
language = Locale.getDefault().language,
snapshot = null,
tokenIds = listOf(params.token.id.value),
),
).onRight { articles ->
state.update { marketsTokenDetailsUM ->
val relatedNews = shortArticleToArticleConfigUMConverter.convert(articles)
marketsTokenDetailsUM.copy(
relatedNews = marketsTokenDetailsUM.relatedNews.copy(
articles = relatedNews,
onArticledClicked = { articledId ->
params.onArticleClick(
/* articledId */ articledId,
/* preselectedIds */ relatedNews.map { it.id },
)
},
),
)
}
}
}
}
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
const val RELATED_NEWS_LIMIT = 10
}
}

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

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

View file

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

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.market.state
package com.tangem.features.feed.model.market.list.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
@ -10,22 +10,21 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
internal data class MarketsListUM(
val list: ListUM,
val searchBar: SearchBarUM,
val marketsSearchBar: MarketsSearchBar,
val selectedSortBy: SortByTypeUM,
val sortByBottomSheet: TangemBottomSheetConfig,
val selectedInterval: TrendInterval,
val onIntervalClick: (TrendInterval) -> Unit,
val onSortByButtonClick: () -> Unit,
val stakingNotificationMaxApy: BigDecimal?,
val onStakingNotificationClick: () -> Unit,
val onStakingNotificationCloseClick: () -> Unit,
val marketsNotificationUM: MarketsNotificationUM?,
val onSearchClicked: () -> Unit,
) {
val isInSearchMode
get() = searchBar.isActive
get() = marketsSearchBar.searchBarUM.isActive &&
marketsSearchBar.searchBarUM.query.isNotEmpty()
enum class TrendInterval(val text: TextReference) {
H24(resourceReference(R.string.markets_selector_interval_24h_title)),
@ -34,7 +33,12 @@ internal data class MarketsListUM(
}
}
enum class SortByTypeUM(val text: TextReference) {
internal data class MarketsSearchBar(
val searchBarUM: SearchBarUM,
val shouldAlwaysShowSearchBar: Boolean,
)
internal enum class SortByTypeUM(val text: TextReference) {
Rating(resourceReference(R.string.markets_sort_by_rating_title)),
Trending(resourceReference(R.string.markets_sort_by_trending_title)),
ExperiencedBuyers(resourceReference(R.string.markets_sort_by_experienced_buyers_title)),
@ -45,7 +49,7 @@ enum class SortByTypeUM(val text: TextReference) {
}
@Immutable
sealed class ListUM {
internal sealed class ListUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,

View file

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

Some files were not shown because too many files have changed in this diff Show more