Updated on 2026-08-14
This commit is contained in:
commit
14a7ac4f5e
431 changed files with 23077 additions and 3701 deletions
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 don’t 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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) } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(""),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue