Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-20 12:47:25 +03:00
commit c50840894f
739 changed files with 27664 additions and 4701 deletions

View file

@ -9,7 +9,7 @@ plugins {
android {
namespace = "com.tangem.features.feed.impl"
packaging {
resources {
merges += "paymentrequest.proto"
@ -17,6 +17,10 @@ android {
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Project - API */
api(projects.features.feed.api)
@ -25,6 +29,7 @@ dependencies {
api(projects.features.tokenRecieve.api)
api(projects.features.wallet.api)
api(projects.features.account.api)
api(projects.features.commonFeatures.api)
implementation(projects.features.promoBanners.api)
/* Data */
@ -101,4 +106,10 @@ dependencies {
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -29,6 +29,9 @@ import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.model.FeedEntryModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.EntryContent
import dagger.assisted.Assisted
@ -87,6 +90,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
route = FeedEntryChildFactory.Child.TokenList(
params = DefaultMarketsTokenListComponent.Params(
preselectedSortType = sortBy ?: SortByTypeUM.Rating,
preselectedInterval = MarketsListUM.TrendInterval.H24,
shouldAlwaysShowSearchBar = sortBy == null,
),
),
@ -128,8 +132,8 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
innerRouter.push(FeedEntryChildFactory.Child.Earn)
}
override fun openSearch() {
innerRouter.push(FeedEntryChildFactory.Child.Search)
override fun openSearch(source: String) {
innerRouter.push(FeedEntryChildFactory.Child.Search(source))
}
}
@ -231,11 +235,15 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
paginationConfig = null,
)
},
preselectedSection = entryRoute.preselectedSection,
shouldOpenExchanges = entryRoute.shouldOpenExchanges,
exchangesCount = entryRoute.exchangesCount,
),
)
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
is FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
DefaultMarketsTokenListComponent.Params(
preselectedSortType = SortByTypeUM.Rating,
preselectedSortType = mapOrderToSortType(entryRoute.preselectedOrder),
preselectedInterval = mapIntervalToTrendInterval(entryRoute.preselectedInterval),
shouldAlwaysShowSearchBar = false,
),
)
@ -265,4 +273,24 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
}
private fun mapOrderToSortType(order: PreselectedMarketsOrder?): SortByTypeUM {
return when (order) {
PreselectedMarketsOrder.Rating -> SortByTypeUM.Rating
PreselectedMarketsOrder.Trending -> SortByTypeUM.Trending
PreselectedMarketsOrder.Buyers -> SortByTypeUM.ExperiencedBuyers
PreselectedMarketsOrder.Gainers -> SortByTypeUM.TopGainers
PreselectedMarketsOrder.Losers -> SortByTypeUM.TopLosers
null -> SortByTypeUM.Rating
}
}
private fun mapIntervalToTrendInterval(interval: PreselectedMarketsInterval?): MarketsListUM.TrendInterval {
return when (interval) {
PreselectedMarketsInterval.H24 -> MarketsListUM.TrendInterval.H24
PreselectedMarketsInterval.W1 -> MarketsListUM.TrendInterval.D7
PreselectedMarketsInterval.D30 -> MarketsListUM.TrendInterval.M1
null -> MarketsListUM.TrendInterval.H24
}
}
internal interface FeedEntryClickIntents : FeedModelClickIntents

View file

@ -11,7 +11,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
@ -65,7 +65,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data object Search : Child
data class Search(val source: String) : Child
}
@Suppress("LongMethod")
@ -144,10 +144,18 @@ internal class FeedEntryChildFactory @Inject constructor(
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
)
}
Child.Search -> DefaultSearchComponent(
is Child.Search -> DefaultSearchComponent(
appComponentContext = appComponentContext,
params = DefaultSearchComponent.Params(
onBackClick = onBackClicked,
onMarketTokenClick = { token, currency ->
feedEntryClickIntents.onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
sourceParams = child.source,
),
)
}

View file

@ -34,7 +34,7 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.earn.EarnModel
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.earn.EarnContent
@ -148,6 +148,6 @@ internal class DefaultEarnComponent(
data class Params(
val onBackClick: () -> Unit,
val onSearchClicked: () -> Unit,
val onSearchClicked: (source: String) -> Unit,
)
}

View file

@ -20,8 +20,8 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent.Params
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent.Params
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.feed.FeedList

View file

@ -2,7 +2,7 @@ package com.tangem.features.feed.components.feed
import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent
import com.tangem.features.feed.components.earn.EarnTypeFilterComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
internal sealed interface FeedBottomSheetRoute {

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
@ -228,6 +229,9 @@ internal class DefaultMarketsTokenDetailsComponent(
val analyticsParams: AnalyticsParams?,
val onBackClicked: () -> Unit,
val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit,
val preselectedSection: PreselectedTokenDetailsSection? = null,
val shouldOpenExchanges: Boolean = false,
val exchangesCount: Int? = null,
)
@Serializable

View file

@ -1,118 +0,0 @@
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.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.router.stack.ChildStack
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.impl.model.AddToPortfolioRoutes
import com.tangem.features.feed.impl.R
@Composable
internal fun AddToPortfolioBottomSheet(
childStack: State<ChildStack<AddToPortfolioRoutes, ComposableContentComponent>>,
onBack: () -> Unit,
onDismiss: () -> Unit,
) {
val stack by childStack
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 = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.tertiary,
title = {
AnimatedContent(targetState = contentStack.value, label = "Title Animation") { animatedStack ->
AddToPortfolioBottomSheetTitle(
stack = animatedStack,
onBackClick = onBack,
modifier = Modifier.fillMaxWidth(),
)
}
},
content = {
AnimatedContent(targetState = contentStack.value, label = "Content Animation") { animatedStack ->
val paddingModifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
)
val isScrollableContent = when (animatedStack.active.configuration) {
AddToPortfolioRoutes.PortfolioSelector -> false
AddToPortfolioRoutes.AddToken,
AddToPortfolioRoutes.Empty,
is AddToPortfolioRoutes.NetworkSelector,
AddToPortfolioRoutes.TokenActions,
-> true
}
if (isScrollableContent) {
Column(
modifier = paddingModifier.verticalScroll(rememberScrollState()),
) {
animatedStack.active.instance.Content(modifier = Modifier)
}
} else {
animatedStack.active.instance.Content(modifier = paddingModifier)
}
}
},
)
}
@Composable
private fun AddToPortfolioBottomSheetTitle(
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,
)
}

View file

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

View file

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

View file

@ -1,110 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
import androidx.compose.runtime.Composable
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
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.decompose.ComposableContentComponent
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.feed.components.market.details.portfolio.add.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 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() {
AddToPortfolioBottomSheet(
childStack = childStack.subscribeAsState(),
onBack = ::onBack,
onDismiss = ::dismiss,
)
}
private fun contentChild(
config: AddToPortfolioRoutes,
componentContext: ComponentContext,
): ComposableContentComponent = when (config) {
AddToPortfolioRoutes.AddToken -> addTokenComponent
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create(
context = childByContext(componentContext),
params = ChooseNetworkComponent.Params(
selectedPortfolio = config.selectedPortfolio,
callbacks = model,
),
)
}
@AssistedFactory
interface Factory : AddToPortfolioComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddToPortfolioComponent.Params,
): DefaultAddToPortfolioComponent
}
}

View file

@ -1,90 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
import androidx.compose.runtime.Composable
import com.arkivanov.decompose.extensions.compose.subscribeAsState
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.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioPreselectedDataModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddToPortfolioPreselectedDataComponent.Params,
portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
addTokenComponentFactory: AddTokenComponent.Factory,
) : AppComponentContext by context, AddToPortfolioPreselectedDataComponent {
private val model: AddToPortfolioPreselectedDataModel = 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 childStack = childStack(
key = "addToPortfolioFromEarnStack",
handleBackButton = true,
source = model.navigation,
serializer = AddToPortfolioRoutes.serializer(),
initialStack = { model.currentStack },
childFactory = { configuration, _ ->
contentChild(configuration)
},
)
private fun onBack() {
if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss()
}
override fun dismiss() {
params.callback.onDismiss()
}
@Composable
override fun BottomSheet() {
AddToPortfolioBottomSheet(
childStack = childStack.subscribeAsState(),
onBack = ::onBack,
onDismiss = ::dismiss,
)
}
private fun contentChild(config: AddToPortfolioRoutes): ComposableContentComponent = when (config) {
AddToPortfolioRoutes.AddToken -> addTokenComponent
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
AddToPortfolioRoutes.TokenActions -> ComposableContentComponent.EMPTY
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> ComposableContentComponent.EMPTY
}
@AssistedFactory
interface Factory : AddToPortfolioPreselectedDataComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddToPortfolioPreselectedDataComponent.Params,
): DefaultAddToPortfolioPreselectedDataComponent
}
}

View file

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

View file

@ -1,116 +0,0 @@
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.account.filterCryptoPortfolio
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.AvailableToAddAccount
import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddData
import com.tangem.features.feed.components.market.details.portfolio.add.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.CryptoPortfolio.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.filterCryptoPortfolio()
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount> = accounts
.mapNotNull { accountStatus ->
val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null
accountStatus.account.accountId to availableToAddAccount
}
.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.CryptoPortfolio,
): CryptoCurrency? {
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = marketParams,
network = network,
accountIndex = account.derivationIndex,
)
}
}

View file

@ -1,28 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.di
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager
import dagger.Binds
import dagger.Module
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
@Binds
fun bindAddToPortfolioPreselectedDataComponent(
factory: DefaultAddToPortfolioPreselectedDataComponent.Factory,
): AddToPortfolioPreselectedDataComponent.Factory
}

View file

@ -1,40 +0,0 @@
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.*
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(AddToPortfolioPreselectedDataModel::class)
fun addToPortfolioPreselectedDataModel(model: AddToPortfolioPreselectedDataModel): Model
@Binds
@IntoMap
@ClassKey(TokenActionsModel::class)
fun tokenActionsModel(model: TokenActionsModel): Model
@Binds
@IntoMap
@ClassKey(ChooseNetworkModel::class)
fun chooseNetworkModel(model: ChooseNetworkModel): Model
}

View file

@ -1,389 +0,0 @@
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.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.*
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 com.tangem.utils.logging.TangemLogger
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 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(
tokenSymbol = addToPortfolioManager.token.symbol,
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()
}
fun finishSuccessFlow(addedToken: CryptoCurrencyStatus) {
params.callback.onSuccess(addedToken.currency)
channel.close()
}
val featureDataFlow: StateFlow<AvailableToAddData> = featureData
.filterIsInstance<AddToPortfolioManager.State.AvailableToAdd>()
.map { it.availableToAddData }
.distinctUntilChanged()
.stateIn(this)
val isAccountMode = portfolioSelectorController.isAccountModeSync()
// use snapshot data, looks like we dont need to remap at runtime
val data = featureDataFlow.value
// you must control it via [AddToPortfolioManager.state]
if (!data.isAvailableToAdd) {
finishFlow()
return@channelFlow
}
// init data flows, emits on user/code selection, updates state holder
val firstSelectedPortfolio = setupPortfolioFlow(data)
.onEach { selectedPortfolio.emit(it) }
val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio)
.onEach { selectedNetwork.emit(it) }
val isSinglePortfolio = data.isSinglePortfolio
if (isSinglePortfolio) {
val accountId = data.availableToAddWallets.values.first()
.availableToAddAccounts.values.first()
.account.account.accountId
// force select a portfolio, triggers [selectedPortfolio]
portfolioSelectorController.selectAccount(accountId)
} else {
logAccountSelector(isAccountMode)
navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector)
}
val firstPartOfNavigation: Job = firstSelectedPortfolio
.onEach { portfolio ->
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
when {
// force select a network, triggers [selectedNetwork]
isSingleAvailableNetwork -> {
val singleNetwork = portfolio.account.availableToAddNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
}
// it's important to control root screen, UI depends on it(close/arrow icon)
isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio))
else -> navigation.pushNew(routeToNetworkSelector(portfolio))
}
}
.launchIn(this)
// main flow that combine all require data
val allRequireForAdd = combine(
flow = firstSelectedNetwork,
flow2 = firstSelectedPortfolio,
transform = { a, b -> a to b },
)
// suspend until all required data is selected
allRequireForAdd.first()
// line of navigation to AddToken screen is finished; cancel the job, select a new root screen
firstPartOfNavigation.cancel()
analyticsEventHandler.send(event = eventBuilder.popupToConfirm())
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()
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)))
if (params.shouldSkipTokenActionsScreen) {
finishSuccessFlow(addedToken)
} else {
setupTokenActionsFlow(selectedPortfolio, addedToken)
.onEach { cryptoCurrencyData ->
tokenActionsData.emit(cryptoCurrencyData)
navigation.replaceAll(AddToPortfolioRoutes.TokenActions)
}
.onEmpty { finishFlow() }
.launchIn(this)
}
callbackDelegate.onLaterClick.receiveAsFlow().first()
finishFlow()
}
.catch { throwable ->
TangemLogger.e("Error", 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 changePortfolioNavigationFlow(data: AvailableToAddData): Flow<Unit> = flow {
val selectedPortfolioValue = selectedPortfolio.first()
val selectedAccount = selectedPortfolioValue.account.account.account.accountId
portfolioSelectorController.selectAccount(selectedAccount)
val changedPortfolio = setupPortfolioFlow(data)
.drop(1)
.onEach { portfolio ->
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
if (isSingleAvailableNetwork) {
val singleNetwork = portfolio.account.availableToAddNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
} else {
navigation.pushNew(routeToNetworkSelector(portfolio))
}
}
val changedNetwork = setupNetworkFlow(changedPortfolio)
combine(
flow = changedPortfolio,
flow2 = changedNetwork,
transform = { newPortfolio, newNetwork ->
selectedPortfolio.tryEmit(newPortfolio)
selectedNetwork.tryEmit(newNetwork)
navigation.popToFirst()
},
).collect { emit(it) }
}
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 = 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 isAvailableAccount =
availableWallet.availableToAddAccounts[accountStatus.account.accountId]
?.isAvailableToAdd == true
return@isEnabled isAvailableAccount
}
AddToPortfolioManager.State.Init,
AddToPortfolioManager.State.NothingToAdd,
-> Unit
}
}
}
@ModelScoped
internal class AddToPortfolioCallbackDelegate @Inject constructor() :
ChooseNetworkComponent.Callbacks,
TokenActionsComponent.Callbacks,
AddTokenComponent.Callbacks {
val onNetworkSelected = Channel<TokenMarketInfo.Network>()
val onLaterClick = Channel<Unit>()
val onChangeNetworkClick = Channel<Unit>()
val onChangePortfolioClick = Channel<Unit>()
val onTokenAdded = Channel<CryptoCurrencyStatus>()
override fun onNetworkSelected(network: TokenMarketInfo.Network) {
onNetworkSelected.trySend(network)
}
override fun onLaterClick() {
onLaterClick.trySend(Unit)
}
override fun onChangeNetworkClick() {
onChangeNetworkClick.trySend(Unit)
}
override fun onChangePortfolioClick() {
onChangePortfolioClick.trySend(Unit)
}
override fun onTokenAdded(status: CryptoCurrencyStatus) {
onTokenAdded.trySend(status)
}
}

View file

@ -1,306 +0,0 @@
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.replaceAll
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
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.GetAccountCurrencyStatusUseCase
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
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.features.account.PortfolioFetcher
import com.tangem.features.account.PortfolioSelectorController
import com.tangem.features.feed.components.market.details.portfolio.add.*
import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList")
internal class AddToPortfolioPreselectedDataModel @Inject constructor(
paramsContainer: ParamsContainer,
portfolioFetcherFactory: PortfolioFetcher.Factory,
override val dispatchers: CoroutineDispatcherProvider,
val portfolioSelectorController: PortfolioSelectorController,
private val callbackDelegate: AddToPortfolioFromEarnCallbackDelegate,
private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) : Model(), AddTokenComponent.Callbacks by callbackDelegate {
private val params = paramsContainer.require<AddToPortfolioPreselectedDataComponent.Params>()
val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true),
scope = modelScope,
)
val navigation = StackNavigation<AddToPortfolioRoutes>()
var currentStack = listOf<AddToPortfolioRoutes>(AddToPortfolioRoutes.Empty)
private val _selectedNetwork = MutableStateFlow<SelectedNetwork?>(null)
val selectedNetwork: Flow<SelectedNetwork> = _selectedNetwork.asStateFlow().filterNotNull()
private val _selectedPortfolio = MutableStateFlow<SelectedPortfolio?>(null)
val selectedPortfolio: Flow<SelectedPortfolio> = _selectedPortfolio.asStateFlow().filterNotNull()
val eventBuilder = PortfolioAnalyticsEvent.EventBuilder(
tokenSymbol = params.tokenToAdd.symbol,
source = AnalyticsParam.ScreensSources.Markets.value,
)
init {
navigation.subscribe { currentStack = it.transformer.invoke(currentStack) }
startAddToPortfolioFlow()
}
@Suppress("LongMethod")
private fun startAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishSuccessFlow(currency: CryptoCurrency, userWalletId: UserWalletId) {
params.callback.onSuccess(addedToken = currency, walletId = userWalletId)
channel.close()
}
val data = createAvailableToAddDataForPreselectedNetwork(params.tokenToAdd.network)
?: return@channelFlow
val isAccountMode = portfolioSelectorController.isAccountModeSync()
val firstSelectedPortfolio = setupPortfolioFlow(data)
.onEach { _selectedPortfolio.value = it }
val firstSelectedNetwork = firstSelectedPortfolio
.map { portfolio -> createSelectedNetwork(network = params.tokenToAdd.network, portfolio = portfolio) }
.filterNotNull()
.onEach { _selectedNetwork.value = 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)
}
// 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
val (selectedNetworkValue, selectedPortfolioValue) = allRequireForAdd.first()
val isTokenAlreadyAdded = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = selectedPortfolioValue.userWallet.walletId,
currency = selectedNetworkValue.cryptoCurrency,
).isSome()
if (isTokenAlreadyAdded) {
finishSuccessFlow(
currency = selectedNetworkValue.cryptoCurrency,
userWalletId = selectedPortfolioValue.userWallet.walletId,
)
return@channelFlow
}
sendAddTokenOpenedAnalytics(selectedNetworkValue.cryptoCurrency)
navigation.replaceAll(AddToPortfolioRoutes.AddToken)
val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first()
messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added)))
sendSuccessAddedAnalytics(addedToken.currency)
finishSuccessFlow(addedToken.currency, selectedPortfolioValue.userWallet.walletId)
}
.catch { throwable ->
TangemLogger.e("Error", throwable)
params.callback.onDismiss()
}
.launchIn(modelScope)
}
private fun logAccountSelector(isAccountMode: Boolean) {
if (isAccountMode) {
analyticsEventHandler.send(eventBuilder.popupToChooseAccount())
}
}
private fun minimalTokenMarketParams() = with(params.tokenToAdd) {
TokenMarketParams(
id = id,
name = name,
symbol = symbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = BigDecimal.ZERO,
h24Percent = null,
weekPercent = null,
monthPercent = null,
),
imageUrl = null,
)
}
/**
* Creates [AvailableToAddData] for preselected network when token is already added in all networks.
* This allows user to select wallet/account and do smth after it with selected info.
*/
private suspend fun createAvailableToAddDataForPreselectedNetwork(
preSelectedNetwork: TokenMarketInfo.Network,
): AvailableToAddData? {
val portfolioData = portfolioFetcher.data.firstOrNull() ?: return null
val availableToAddInWallets = portfolioData.balances.mapNotNull { (walletId, balance) ->
val wallet = balance.userWallet
val accounts = balance.accountsBalance.accountStatuses.filterCryptoPortfolio()
val availableToAddAccounts = accounts.mapNotNull { accountStatus ->
val accountIndex = when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.account.derivationIndex
}
val cryptoCurrency = getTokenMarketCryptoCurrency(
userWalletId = walletId,
tokenMarketParams = minimalTokenMarketParams(),
network = preSelectedNetwork,
accountIndex = accountIndex,
) ?: return@mapNotNull null
val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = walletId,
currency = cryptoCurrency,
).fold(
ifEmpty = { emptySet() },
ifSome = { setOf(it.status.currency.network) },
)
AvailableToAddAccount(
account = accountStatus,
availableNetworks = setOf(preSelectedNetwork),
addedNetworks = addedNetworks,
)
}.associateBy { it.account.account.accountId }
if (availableToAddAccounts.isEmpty()) return@mapNotNull null
walletId to AvailableToAddWallet(
userWallet = wallet,
accounts = accounts,
availableNetworks = setOf(preSelectedNetwork),
availableToAddAccounts = availableToAddAccounts,
)
}.toMap()
if (availableToAddInWallets.isEmpty()) return null
return AvailableToAddData(availableToAddWallets = availableToAddInWallets)
}
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
account: AvailableToAddAccount,
): CryptoCurrency? {
val accountIndex = when (val accountStatus = account.account) {
is AccountStatus.CryptoPortfolio -> accountStatus.account.derivationIndex
is AccountStatus.Payment -> return null
}
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = minimalTokenMarketParams(),
network = network,
accountIndex = accountIndex,
)
}
private suspend fun createSelectedNetwork(
network: TokenMarketInfo.Network,
portfolio: SelectedPortfolio,
): SelectedNetwork? {
val cryptoCurrency = createCryptoCurrency(
userWallet = portfolio.userWallet,
network = network,
account = portfolio.account,
) ?: return null
return SelectedNetwork(
cryptoCurrency = cryptoCurrency,
selectedNetwork = network,
isAvailableMoreNetwork = false,
)
}
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 = false,
)
},
)
.filterNotNull()
private fun sendSuccessAddedAnalytics(cryptoCurrency: CryptoCurrency) {
analyticsEventHandler.send(
EarnAnalyticsEvent.TokenAdded(
tokenSymbol = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
),
)
}
private fun sendAddTokenOpenedAnalytics(cryptoCurrency: CryptoCurrency) {
analyticsEventHandler.send(
EarnAnalyticsEvent.AddTokenScreenOpened(
tokenSymbol = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
source = params.analyticsParams.source,
),
)
}
}
@ModelScoped
internal class AddToPortfolioFromEarnCallbackDelegate @Inject constructor() :
AddTokenComponent.Callbacks {
val onTokenAdded = Channel<CryptoCurrencyStatus>()
override fun onChangeNetworkClick() = Unit
override fun onChangePortfolioClick() = Unit
override fun onTokenAdded(status: CryptoCurrencyStatus) {
onTokenAdded.trySend(status)
}
}

View file

@ -1,28 +0,0 @@
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.SelectedPortfolio
import kotlinx.serialization.Serializable
@Serializable
@Immutable
internal sealed interface AddToPortfolioRoutes : Route {
@Serializable
data object Empty : AddToPortfolioRoutes
@Serializable
data object PortfolioSelector : AddToPortfolioRoutes
@Serializable
data class NetworkSelector(
val selectedPortfolio: SelectedPortfolio,
) : AddToPortfolioRoutes
@Serializable
data object AddToken : AddToPortfolioRoutes
@Serializable
data object TokenActions : AddToPortfolioRoutes
}

View file

@ -1,135 +0,0 @@
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.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork
import com.tangem.features.feed.components.market.details.portfolio.add.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,
private val checkCurrencyUnsupportedDelegate: CheckCurrencyUnsupportedDelegate,
) : 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.join()
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
val cryptoCurrency = selectedNetwork.cryptoCurrency
val account = selectedPortfolio.account.account.account
val accountId = account.accountId
val isMainNetwork = selectedNetwork.selectedNetwork.contractAddress == null
val unsupportedCurrency = checkCurrencyUnsupportedDelegate.checkCurrencyUnsupportedState(
userWalletId = accountId.userWalletId,
rawNetworkId = selectedNetwork.selectedNetwork.networkId,
isMainNetwork = isMainNetwork,
)
if (unsupportedCurrency != null) return@launch
uiState.value = um.toggleProgress(true)
val blockchainNames = listOf(selectedNetwork.selectedNetwork)
.mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name }
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames))
analyticsEventHandler.send(analyticsEventBuilder.addButtonClick())
manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency)
.onLeft { throwable ->
processError(error = throwable)
uiState.value = um.toggleProgress(false)
return@launch
}
val status = getAccountCurrencyStatusUseCase(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
).firstOrNull()
if (status == null) {
processError(error = null)
} else {
if (!account.isMainAccount) {
analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount())
}
analyticsEventHandler.send(
event = analyticsEventBuilder.tokenAdded(status.status.currency.network.name),
)
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 selectedNetwork.cryptoCurrency.network.derivationPath.value,
),
)
private fun processError(error: Throwable?) {
val message = error?.message?.let { stringReference(it) }
?: resourceReference(R.string.common_something_went_wrong)
messageSender.send(ToastMessage(message = message))
}
}

View file

@ -1,107 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
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.SelectedNetwork
import com.tangem.features.feed.components.market.details.portfolio.add.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: AccountIconUM?
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 CryptoPortfolio -> CryptoPortfolioIconConverter.convert(accountStatus.account.icon)
is Payment -> AccountIconUM.Payment
}
}
}
return PortfolioSelectUM(
icon = accountIcon,
name = portfolioName,
isAccountMode = selectedPortfolio.isAccountMode,
isMultiChoice = selectedPortfolio.isAvailableMorePortfolio,
onClick = { params.callbacks.onChangePortfolioClick() },
)
}
fun updateContent(
selectedPortfolio: SelectedPortfolio,
selectedNetwork: SelectedNetwork,
isTangemIconVisible: Boolean,
onConfirmClick: () -> Unit,
): AddTokenUM {
// its may happens when change portfolio after selected both params in line navigation
val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks
.any { selectedNetwork.selectedNetwork.networkId == it.networkId }
val button = AddTokenUM.Button(
isEnabled = isAvailableNetwork,
showProgress = false,
isTangemIconVisible = isTangemIconVisible,
text = resourceReference(R.string.common_add),
onConfirmClick = onConfirmClick,
)
val networkUM = createNetwork(selectedNetwork)
val portfolioUM = createPortfolio(selectedPortfolio)
val currency = selectedNetwork.cryptoCurrency
val tokenToAdd = TokenItemState.Content(
id = currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(currency),
titleState = TokenItemState.TitleState.Content(stringReference(currency.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""),
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return AddTokenUM(
tokenToAdd = tokenToAdd,
network = networkUM,
portfolio = portfolioUM,
button = button,
)
}
companion object {
fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy(
button = this.button.copy(showProgress = showProgress),
)
}
}

View file

@ -1,77 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import arrow.core.getOrElse
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.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.models.wallet.UserWalletId
import com.tangem.features.feed.impl.R
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
class CheckCurrencyUnsupportedDelegate @Inject constructor(
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val messageSender: UiMessageSender,
) {
suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
val result = checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse { throwable ->
TangemLogger.e(
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
throwable,
)
val message = SnackbarMessage(
message = throwable.localizedMessage?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
if (result != null) {
showUnsupportedWarning(result)
}
return result
}
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_blockchain_by_card_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
)
messageSender.send(message)
}
}

View file

@ -1,60 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
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.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
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.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class ChooseNetworkModel @Inject constructor(
paramsContainer: ParamsContainer,
private val checkCurrencyUnsupportedDelegate: CheckCurrencyUnsupportedDelegate,
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 = checkCurrencyUnsupportedDelegate.checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = row.id,
isMainNetwork = row.isMainNetwork,
)
if (unsupportedState == null) {
params.callbacks.onNetworkSelected(network)
}
}
}

View file

@ -1,75 +0,0 @@
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 },
onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) },
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
val uiState: StateFlow<TokenActionsUM?> = params.data
.mapLatest { uiBuilder.build(it, tokenActionsHandler) }
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) {
val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action)
analyticsEventHandler.send(event)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
}
}

View file

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

View file

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

View file

@ -1,71 +0,0 @@
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.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter
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: AddToPortfolioManager.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: AddToPortfolioManager.AnalyticsParams?,
): DefaultAddToPortfolioManager
}
}

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ 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.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.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
@ -66,7 +66,6 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.addToPortfolioManager,
callback = model.addToPortfolioCallback,
),
)
is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create(

View file

@ -1,7 +1,7 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.analytics
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
internal class PortfolioAnalyticsEvent(
event: String,
@ -21,60 +21,6 @@ internal class PortfolioAnalyticsEvent(
},
)
fun popupToChooseAccount() = PortfolioAnalyticsEvent(
event = "Choose Account Opened",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun popupToConfirm() = PortfolioAnalyticsEvent(
event = "Add Token Screen Opened",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToNotMainAccount() = PortfolioAnalyticsEvent(
event = "Button - Add To Account",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addButtonClick() = PortfolioAnalyticsEvent(
event = "Button - Add Token",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(
event = "Wallet Selected",
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToPortfolioContinue(blockchainNames: List<String>) = PortfolioAnalyticsEvent(
event = "Token Network Selected",
params = buildMap {
put("Count", blockchainNames.size.toString())
put("Token", tokenSymbol)
put("blockchain", blockchainNames.joinToString(separator = ", "))
if (source != null) put("Source", source)
},
)
fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent(
event = "Token Added",
params = buildMap {
put("Token", tokenSymbol)
put("Blockchain", blockchainName)
if (source != null) put("Source", source)
},
)
fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) =
PortfolioAnalyticsEvent(
event = when (actionUM) {
@ -91,25 +37,5 @@ internal class PortfolioAnalyticsEvent(
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"
},
params = buildMap {
if (source != null) put("Source", source)
},
)
fun getTokenLater() = PortfolioAnalyticsEvent(
event = "Popup Get token - Button Later",
params = buildMap {
if (source != null) put("Source", source)
},
)
}
}

View file

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

View file

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

View file

@ -5,6 +5,8 @@ import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
@ -28,7 +30,6 @@ import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
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
@ -248,7 +249,7 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor(
accountWithAdded.addedCurrency.forEach { currencyStatus ->
val actions = allActions[currencyStatus.currency]?.states.orEmpty()
val value = PortfolioData.CryptoCurrencyData(
val value = CryptoCurrencyData(
userWallet = userWallet,
status = currencyStatus,
actions = actions,

View file

@ -5,6 +5,8 @@ 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.markets.action.TokenActionsBSContentUM
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -12,14 +14,11 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
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.ui.state.MyPortfolioUM
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.*
@ -57,16 +56,18 @@ internal class MarketsPortfolioModel @Inject constructor(
private val marketsPortfolioDelegate: MarketsPortfolioDelegate = createMarketsPortfolioDelegate()
val bottomSheetNavigation: SlotNavigation<MarketsPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) = bottomSheetNavigation.dismiss()
}
init {
marketsPortfolioDelegate.combineData()
.onEach { state.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
addToPortfolioManager.onDismiss.receiveAsFlow()
.onEach { bottomSheetNavigation.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { bottomSheetNavigation.dismiss() }
.launchIn(modelScope)
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
@ -82,9 +83,9 @@ internal class MarketsPortfolioModel @Inject constructor(
private fun createAddToPortfolioManager(): AddToPortfolioManager {
return addToPortfolioManagerFactory.create(
scope = modelScope,
token = params.token,
analyticsParams = params.analyticsParams?.source?.let { AddToPortfolioManager.AnalyticsParams(it) },
)
settings = AddToPortfolioManager.Settings.DefaultMarket,
analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source),
).also { addToPortfolioManager -> addToPortfolioManager.setTokenParams(params.token) }
}
private fun createMarketsPortfolioDelegate(): MarketsPortfolioDelegate {
@ -94,11 +95,14 @@ internal class MarketsPortfolioModel @Inject constructor(
tokenActionsHandler = tokenActionsHandler,
buttonState = addToPortfolioManager.state.map { state ->
when (state) {
is AddToPortfolioManager.State.AvailableToAdd -> {
MyPortfolioUM.Tokens.AddButtonState.Available
is AddToPortfolioManager.State.Ready -> {
if (state.isAvailableToAdd) {
MyPortfolioUM.Tokens.AddButtonState.Available
} else {
MyPortfolioUM.Tokens.AddButtonState.Unavailable
}
}
AddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading
AddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable
AddToPortfolioManager.State.Loading -> MyPortfolioUM.Tokens.AddButtonState.Loading
}
},
onAddClick = {

View file

@ -1,19 +1,16 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions
import com.tangem.common.ui.markets.action.TokenActionsHandler
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]
@ -25,10 +22,10 @@ internal class PortfolioTokenUMConverter(
private val isBalanceHidden: Boolean,
private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit,
private val tokenActionsHandler: TokenActionsHandler,
) : Converter<PortfolioData.CryptoCurrencyData, PortfolioTokenUM> {
) : Converter<CryptoCurrencyData, PortfolioTokenUM> {
fun convertV2(
value: PortfolioData.CryptoCurrencyData,
value: CryptoCurrencyData,
isQuickActionsShown: Boolean,
onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit,
): PortfolioTokenUM {
@ -45,7 +42,7 @@ internal class PortfolioTokenUMConverter(
)
}
override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM {
override fun convert(value: CryptoCurrencyData): PortfolioTokenUM {
val tokenItemStateConverter = TokenItemStateConverter(
appCurrency = appCurrency,
titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) },
@ -63,62 +60,4 @@ internal class PortfolioTokenUMConverter(
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.shouldShowBadge)
is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive
is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake
is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy)
else -> null
}?.let(::add)
}
}
}.toImmutableList()
}
}

View file

@ -1,178 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
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.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
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.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 getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
) {
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 -> Unit
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 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) {
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
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
router.push(
AppRoute.YieldSupplyEntry(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrency = cryptoCurrencyData.status.currency,
apy = yieldSupplyApy,
),
)
}
@AssistedFactory
interface Factory {
fun create(
currentAppCurrency: Provider<AppCurrency>,
onHandleQuickAction: (HandledQuickAction) -> Unit,
): TokenActionsHandler
}
data class HandledQuickAction(
val action: TokenActionsBSContentUM.Action,
val cryptoCurrencyData: PortfolioData.CryptoCurrencyData,
)
}

View file

@ -28,6 +28,7 @@ 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.common.ui.markets.action.QuickActionUM
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.extensions.resolveReference
@ -36,7 +37,6 @@ import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

@ -3,6 +3,8 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.ui.pre
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.markets.action.QuickActionUM
import com.tangem.common.ui.markets.action.QuickActions
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
@ -125,7 +127,7 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider<MyPortfol
onItemLongClick = {},
),
isQuickActionsShown = false,
quickActions = PortfolioTokenUM.QuickActions(
quickActions = QuickActions(
actions = persistentListOf(
QuickActionUM.Buy,
QuickActionUM.Exchange(shouldShowBadge = true),

View file

@ -2,10 +2,10 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.ui.sta
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.markets.action.QuickActions
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 {
@ -30,10 +30,4 @@ internal data class PortfolioTokenUM(
val quickActions: QuickActions,
) : PortfolioListItem {
override val id: String = tokenItemState.id
data class QuickActions(
val actions: ImmutableList<QuickActionUM>,
val onQuickActionClick: (QuickActionUM) -> Unit,
val onQuickActionLongClick: (QuickActionUM) -> Unit,
)
}

View file

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

View file

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

View file

@ -1,6 +1,5 @@
package com.tangem.features.feed.components.market.list
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
@ -30,6 +29,7 @@ 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.model.market.list.MarketsListModel
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.market.list.MarketsList
@ -65,9 +65,8 @@ internal class DefaultMarketsTokenListComponent(
.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
endIntensity = .2f,
preferPerformance = true,
easing = EaseOut,
)
},
startContent = {
@ -129,12 +128,13 @@ internal class DefaultMarketsTokenListComponent(
@Serializable
data class Params(
val preselectedSortType: SortByTypeUM,
val preselectedInterval: MarketsListUM.TrendInterval,
val shouldAlwaysShowSearchBar: Boolean,
)
data class ClickIntents(
val onBackClicked: () -> Unit,
val onSearchClicked: () -> Unit,
val onSearchClicked: (source: String) -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
)

View file

@ -1,6 +1,5 @@
package com.tangem.features.feed.components.news.list
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
@ -52,10 +51,10 @@ internal class DefaultNewsListComponent(
modifier = Modifier.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
endIntensity = .2f,
preferPerformance = true,
easing = EaseOut,
)
backgroundColor = background
},
title = resourceReference(R.string.common_news),
type = TangemTopBarType.BottomSheet,

View file

@ -6,15 +6,22 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.field.search.TangemFieldShape
import com.tangem.core.ui.ds.field.search.TangemSearchField
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.search.SearchModel
import com.tangem.features.feed.ui.search.SearchContent
import com.tangem.features.feed.ui.search.state.SearchCallbacks
@ -27,6 +34,13 @@ internal class DefaultSearchComponent(
private val model = getOrCreateModel<SearchModel, Params>(params = params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
@ -67,6 +81,7 @@ internal class DefaultSearchComponent(
contentPadding: PaddingValues,
modifier: Modifier,
) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by model.state.collectAsStateWithLifecycle()
val searchCallbacks = remember {
SearchCallbacks(
@ -74,6 +89,7 @@ internal class DefaultSearchComponent(
onClearHintsClick = model::clearSearchHistory,
onTextHintClick = model::onTextHintClick,
onResultMarketTokenClick = model::onResultMarketTokenClick,
onHistoryTokenClick = model::onHistoryTokenClick,
)
}
SearchContent(
@ -82,9 +98,28 @@ internal class DefaultSearchComponent(
searchCallbacks = searchCallbacks,
contentPadding = contentPadding,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: SearchBottomSheetRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is SearchBottomSheetRoute.TokenSelector -> SearchTokenSelectorComponent(
context = childByContext(componentContext),
params = SearchTokenSelectorComponent.Params(
entries = config.entries,
appCurrency = config.appCurrency,
isBalanceHidden = config.isBalanceHidden,
onTokenSelected = config.onTokenSelected,
onDismiss = config.onDismiss,
),
)
}
data class Params(
val onBackClick: () -> Unit,
val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val sourceParams: String,
)
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.feed.components.search
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
internal sealed interface SearchBottomSheetRoute {
data class TokenSelector(
val entries: List<UserAssetSearchEntry>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val onTokenSelected: (UserAssetSearchEntry) -> Unit,
val onDismiss: () -> Unit,
) : SearchBottomSheetRoute
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.feed.components.search
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.model.search.SearchTokenSelectorModel
import com.tangem.features.feed.ui.search.components.TokenSelectorBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
internal class SearchTokenSelectorComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableBottomSheetComponent {
private val model = getOrCreateModel<SearchTokenSelectorModel, Params>(params = params)
override fun dismiss() {
params.onDismiss()
}
@Composable
override fun BottomSheet() {
val state = model.state.collectAsStateWithLifecycle()
TokenSelectorBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = state.value,
),
)
}
data class Params(
val entries: List<UserAssetSearchEntry>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val onTokenSelected: (UserAssetSearchEntry) -> Unit,
val onDismiss: () -> Unit,
)
}

View file

@ -2,20 +2,31 @@ package com.tangem.features.feed.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.INTERVAL_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.ORDER_KEY
import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultMarketsDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
appRouter: AppRouter,
) : MarketsDeepLinkHandler {
init {
appRouter.push(AppRoute.Markets)
appRouter.push(
AppRoute.Markets(
preselectedOrder = PreselectedMarketsOrder.parse(queryParams[ORDER_KEY]),
preselectedInterval = PreselectedMarketsInterval.parse(queryParams[INTERVAL_KEY]),
),
)
}
@AssistedFactory
interface Factory : MarketsDeepLinkHandler.Factory {
override fun create(): DefaultMarketsDeepLinkHandler
override fun create(params: Map<String, String>): DefaultMarketsDeepLinkHandler
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.features.feed.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.SECTION_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -11,12 +13,12 @@ import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@ -32,8 +34,15 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc
private fun handleDeepLink() {
val tokenId = queryParams[TOKEN_ID_KEY]
val section = PreselectedTokenDetailsSection.parse(queryParams[SECTION_KEY])
val rawTokenId = CryptoCurrency.RawID(tokenId.orEmpty())
if (tokenId.isNullOrEmpty()) {
TangemLogger.e("Markets token details deeplink does not contain token_id")
appRouter.push(AppRoute.Markets())
return
}
val rawTokenId = CryptoCurrency.RawID(tokenId)
scope.launch {
val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse {
@ -65,6 +74,7 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc
appCurrency = appCurrency,
shouldShowPortfolio = true,
analyticsParams = null,
preselectedSection = section,
),
)
}

View file

@ -0,0 +1,88 @@
package com.tangem.features.feed.deeplink
import arrow.core.getOrElse
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
internal class DefaultMarketsTokenExchangesDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@Assisted private val queryParams: Map<String, String>,
private val appRouter: AppRouter,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
) : MarketsTokenExchangesDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
val tokenId = queryParams[TOKEN_ID_KEY]
if (tokenId.isNullOrEmpty()) {
TangemLogger.e("Markets token exchanges deeplink does not contain token_id")
appRouter.push(AppRoute.Markets())
return
}
val rawTokenId = CryptoCurrency.RawID(tokenId)
scope.launch {
val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse {
AppCurrency.Default
}
val tokenInfo = getTokenMarketInfoUseCase(
appCurrency = appCurrency,
tokenId = rawTokenId,
tokenSymbol = "",
).getOrElse {
TangemLogger.e("Failed to get market token info for exchanges deeplink")
appRouter.push(AppRoute.Markets())
return@launch
}
appRouter.push(
AppRoute.MarketsTokenDetails(
token = TokenMarketParams(
id = rawTokenId,
name = tokenInfo.name,
symbol = tokenInfo.symbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = tokenInfo.quotes.currentPrice,
h24Percent = tokenInfo.quotes.h24ChangePercent,
weekPercent = tokenInfo.quotes.weekChangePercent,
monthPercent = tokenInfo.quotes.monthChangePercent,
),
imageUrl = getTokenIconUrlFromDefaultHost(rawTokenId),
),
appCurrency = appCurrency,
shouldShowPortfolio = true,
shouldOpenExchanges = true,
exchangesCount = tokenInfo.exchangesAmount,
),
)
}
}
@AssistedFactory
interface Factory : MarketsTokenExchangesDeepLinkHandler.Factory {
override fun create(
coroutineScope: CoroutineScope,
queryParams: Map<String, String>,
): DefaultMarketsTokenExchangesDeepLinkHandler
}
}

View file

@ -2,8 +2,10 @@ package com.tangem.features.feed.deeplink.di
import com.tangem.features.feed.deeplink.DefaultMarketsDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultMarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.deeplink.DefaultMarketsTokenExchangesDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -23,4 +25,10 @@ internal interface MarketsDeepLinkModule {
fun bindMarketsTokenDetailDeepLinkHandlerFactory(
impl: DefaultMarketsTokenDetailDeepLinkHandler.Factory,
): MarketsTokenDetailDeepLinkHandler.Factory
@Binds
@Singleton
fun bindMarketsTokenExchangesDeepLinkHandlerFactory(
impl: DefaultMarketsTokenExchangesDeepLinkHandler.Factory,
): MarketsTokenExchangesDeepLinkHandler.Factory
}

View file

@ -1,23 +0,0 @@
package com.tangem.features.feed.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.featuretoggle.DefaultFeedFeatureToggle
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object FeedFeatureToggleModule {
@Provides
@Singleton
fun provideFeedFeatureToggle(featureTogglesManager: FeatureTogglesManager): FeedFeatureToggle {
return DefaultFeedFeatureToggle(
featureTogglesManager = featureTogglesManager,
)
}
}

View file

@ -12,6 +12,7 @@ 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 com.tangem.features.feed.model.search.SearchModel
import com.tangem.features.feed.model.search.SearchTokenSelectorModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -71,4 +72,9 @@ internal interface ModelModule {
@IntoMap
@ClassKey(SearchModel::class)
fun provideSearchModel(model: SearchModel): Model
@Binds
@IntoMap
@ClassKey(SearchTokenSelectorModel::class)
fun provideSearchTokenSelectorModel(model: SearchTokenSelectorModel): Model
}

View file

@ -1,13 +0,0 @@
package com.tangem.features.feed.featuretoggle
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
internal class DefaultFeedFeatureToggle(
private val featureTogglesManager: FeatureTogglesManager,
) : FeedFeatureToggle {
override val isEarnBlockEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.EARN_BLOCK_ENABLED)
}

View file

@ -8,6 +8,7 @@ import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -25,7 +26,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent
import com.tangem.features.feed.components.earn.EarnTypeFilterComponent
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent
import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkConverter
import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConverter
@ -325,7 +326,7 @@ internal class EarnModel @Inject constructor(
onNetworkFilterClick = ::onNetworkFilterClick,
onTypeFilterClick = ::onTypeFilterClick,
onScroll = ::onMostlyUsedScrolled,
onSearchBarClicked = params.onSearchClicked,
onSearchBarClicked = { params.onSearchClicked(AnalyticsParam.ScreensSources.Earn.value) },
),
)
}

View file

@ -50,30 +50,6 @@ internal sealed class EarnAnalyticsEvent(
),
)
data class AddTokenScreenOpened(
private val tokenSymbol: String,
private val blockchain: String,
private val source: String,
) : EarnAnalyticsEvent(
event = "Add Token Screen Opened",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.BLOCKCHAIN to blockchain,
AnalyticsParam.SOURCE to source,
),
)
data class TokenAdded(
private val tokenSymbol: String,
private val blockchain: String,
) : EarnAnalyticsEvent(
event = "Token Added",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.BLOCKCHAIN to blockchain,
),
)
data class BestOpportunitiesLoadError(
private val code: Int?,
private val message: String,

View file

@ -31,8 +31,7 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
@ -63,7 +62,6 @@ internal class FeedComponentModel @Inject constructor(
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val stateController: FeedStateController,
private val feedFeatureToggle: FeedFeatureToggle,
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
private val appRouter: AppRouter,
@ -147,7 +145,6 @@ internal class FeedComponentModel @Inject constructor(
}
},
analyticsEventHandler = analyticsEventHandler,
feedFeatureToggle = feedFeatureToggle,
)
val currentState = stateController.value
@ -171,7 +168,7 @@ internal class FeedComponentModel @Inject constructor(
analyticsEventHandler = analyticsEventHandler,
),
UpdateEarnStateTransformer(
isEarnEnabled = feedFeatureToggle.isEarnBlockEnabled,
isEarnEnabled = true,
onItemClick = ::handleEarnTokenClick,
onRetryClick = ::fetchEarnData,
earnResult = earnResult,
@ -206,7 +203,6 @@ internal class FeedComponentModel @Inject constructor(
}
private fun fetchEarnData() {
if (!feedFeatureToggle.isEarnBlockEnabled) return
modelScope.launch(dispatchers.default) {
stateController.update(UpdateEarnLoadingStateTransformer())
fetchTopEarnTokensUseCase()
@ -244,7 +240,7 @@ internal class FeedComponentModel @Inject constructor(
onBarClick = {
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
if (designFeatureToggles.isRedesignEnabled) {
params.feedClickIntents.openSearch()
params.feedClickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value)
} else {
params.feedClickIntents.onMarketOpenClick(null)
}
@ -280,11 +276,7 @@ internal class FeedComponentModel @Inject constructor(
currentSortByType = SortByTypeUM.TopGainers,
),
globalState = GlobalFeedState.Loading,
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
EarnListUM.Empty
},
earnListUM = EarnListUM.Loading,
)
}

View file

@ -30,5 +30,5 @@ internal interface FeedModelClickIntents {
fun onOpenEarnPage()
fun openSearch()
fun openSearch(source: String)
}

View file

@ -2,7 +2,6 @@ package com.tangem.features.feed.model.feed.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
@ -16,9 +15,7 @@ import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class FeedStateController @Inject constructor(
private val feedFeatureToggle: FeedFeatureToggle,
) {
internal class FeedStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<FeedListUM> = MutableStateFlow(value = getInitialState())
@ -67,11 +64,7 @@ internal class FeedStateController @Inject constructor(
currentSortByType = SortByTypeUM.Trending,
),
globalState = GlobalFeedState.Loading,
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
EarnListUM.Empty
},
earnListUM = EarnListUM.Loading,
)
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.features.feed.model.feed.state.transformers
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.models.earn.EarnTopToken
import com.tangem.domain.models.news.TrendingNews
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
@ -19,16 +18,13 @@ internal class UpdateGlobalFeedStateTransformer(
private val earnResult: EarnTopToken?,
private val onRetryClicked: () -> Unit,
private val analyticsEventHandler: AnalyticsEventHandler,
private val feedFeatureToggle: FeedFeatureToggle,
) : FeedListUMTransformer {
override fun transform(prevState: FeedListUM): FeedListUM {
val blockStates = buildList {
add(getNewsState(prevState))
add(getChartsState())
if (feedFeatureToggle.isEarnBlockEnabled) {
add(getEarnState(prevState))
}
add(getEarnState(prevState))
}
val newGlobalState = when {

View file

@ -5,6 +5,7 @@ import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.TangemSiteShareUrlBuilder
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.sorted
@ -21,6 +22,7 @@ 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.event.triggeredEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
@ -95,6 +97,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val quotesJob = JobHolder()
private var userCountry: UserCountry? = null
private var isScrollToSectionHandled = false
private val params = paramsContainer.require<DefaultMarketsTokenDetailsComponent.Params>()
private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token)
@ -297,6 +300,15 @@ internal class MarketsTokenDetailsModel @Inject constructor(
initialLoad()
loadRelatedNews()
if (params.shouldOpenExchanges) {
modelScope.launch {
val exchangesCount = params.exchangesCount
?: currentTokenInfo.value?.exchangesAmount
?: 0
onListedOnClick(exchangesCount)
}
}
}
private fun initialLoad() {
@ -519,6 +531,14 @@ internal class MarketsTokenDetailsModel @Inject constructor(
description = descriptionConverter.convert(newInfo),
infoBlocks = infoConverter.convert(newInfo),
),
scrollToSection = if (!isScrollToSectionHandled) {
mapSectionToKey(params.preselectedSection)?.let { key ->
isScrollToSectionHandled = true
triggeredEvent(data = key, onConsume = ::consumeScrollToSection)
} ?: consumedEvent()
} else {
marketsTokenDetailsUM.scrollToSection
},
)
}
@ -526,7 +546,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
val networks = newInfo.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
networkId = network.networkId,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = isAllWalletsIsHot,
@ -755,6 +775,17 @@ internal class MarketsTokenDetailsModel @Inject constructor(
)
}
private fun mapSectionToKey(section: PreselectedTokenDetailsSection?): String? {
return when (section) {
PreselectedTokenDetailsSection.News -> MarketsTokenDetailsUM.RelatedNews.SECTION_KEY
null -> null
}
}
private fun consumeScrollToSection() {
state.update { it.copy(scrollToSection = consumedEvent()) }
}
private companion object {
const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L
const val RELATED_NEWS_LIMIT = 10

View file

@ -4,6 +4,7 @@ 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.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -67,9 +68,10 @@ internal class MarketsListModel @Inject constructor(
onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) },
shouldAlwaysShowSearchBar = Provider { modelParams.params.shouldAlwaysShowSearchBar },
preselectedSortType = Provider { modelParams.params.preselectedSortType },
preselectedInterval = Provider { modelParams.params.preselectedInterval },
onBackClick = modelParams.clickIntents.onBackClicked,
analyticsEventHandler = analyticsEventHandler,
onSearchBarClick = modelParams.clickIntents.onSearchClicked,
onSearchBarClick = { modelParams.clickIntents.onSearchClicked(AnalyticsParam.ScreensSources.Market.value) },
)
}

View file

@ -141,6 +141,21 @@ internal class MarketsListBatchFlowManager(
initialValue = false,
)
val initialLoadingError: Flow<Throwable> = batchFlow.state
.map { it.status }
.distinctUntilChanged()
.filterIsInstance<PaginationStatus.InitialLoadingError>()
.map { it.throwable }
val totalCount: StateFlow<Int?> = batchFlow.state
.map { it.totalCount }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
val isSearchNotFoundState = batchFlow.state
.map { batchListState ->
currentSearchText().isNullOrEmpty().not() &&

View file

@ -27,6 +27,7 @@ internal class MarketsListUMStateManager(
private val shouldAlwaysShowSearchBar: Provider<Boolean>,
private val currentVisibleIds: Provider<List<CryptoCurrency.RawID>>,
private val preselectedSortType: Provider<SortByTypeUM>,
private val preselectedInterval: Provider<MarketsListUM.TrendInterval>,
private val onLoadMoreUiItems: () -> Unit,
private val visibleItemsChanged: (itemsKeys: List<CryptoCurrency.RawID>) -> Unit,
private val onRetryButtonClicked: () -> Unit,
@ -229,7 +230,7 @@ internal class MarketsListUMStateManager(
shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(),
),
selectedSortBy = preselectedSortType(),
selectedInterval = MarketsListUM.TrendInterval.H24,
selectedInterval = preselectedInterval(),
onIntervalClick = { selectedInterval = it },
onSortByButtonClick = { isSortByBottomSheetShown = true },
sortByBottomSheet = TangemBottomSheetConfig(

View file

@ -1,65 +1,72 @@
package com.tangem.features.feed.model.search
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.markets.*
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase
import com.tangem.domain.search.usecase.GetSearchResultsUseCase
import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase
import com.tangem.domain.search.usecase.SaveSearchQueryUseCase
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.feed.components.search.SearchBottomSheetRoute
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager
import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecentSearchTokenConverter
import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency
import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter
import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency
import com.tangem.features.feed.model.search.analytics.SearchAnalyticsHelper
import com.tangem.features.feed.model.search.converter.*
import com.tangem.features.feed.model.search.state.SearchStateController
import com.tangem.features.feed.model.search.state.transformers.*
import com.tangem.features.feed.ui.search.state.*
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import com.tangem.features.feed.ui.search.state.TextHintItemUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
private const val MARKET_SEARCH_DEBOUNCE_MS = 500L
private const val RESULTS_SHOWN_DEBOUNCE_MS = 1000L
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
@ModelScoped
internal class SearchModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getSearchResultsUseCase: GetSearchResultsUseCase,
private val saveSearchQueryUseCase: SaveSearchQueryUseCase,
private val saveRecentSearchTokenUseCase: SaveRecentSearchTokenUseCase,
private val clearSearchHistoryUseCase: ClearSearchHistoryUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val appRouter: AppRouter,
private val stateController: SearchStateController,
private val searchAnalyticsHelper: SearchAnalyticsHelper,
) : Model() {
private val params = paramsContainer.require<DefaultSearchComponent.Params>()
@ -68,7 +75,6 @@ internal class SearchModel @Inject constructor(
private val searchResultsJob = JobHolder()
private val marketSearchDebounceJob = JobHolder()
private var shouldShowAllTokensIncludingUnder100k = false
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
@ -77,6 +83,13 @@ internal class SearchModel @Inject constructor(
initialValue = AppCurrency.Default,
)
private val isBalanceHidden = getBalanceHidingSettingsUseCase.isBalanceHidden()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
private val marketsListItemToRecentSearchTokenConverter by lazy {
MarketsListItemUMToRecentSearchTokenConverter()
}
@ -102,6 +115,8 @@ internal class SearchModel @Inject constructor(
)
}
val bottomSheetNavigation: SlotNavigation<SearchBottomSheetRoute> = SlotNavigation()
val state: StateFlow<SearchUM> get() = stateController.uiState
init {
@ -110,7 +125,10 @@ internal class SearchModel @Inject constructor(
subscribeToMarketUiItems()
subscribeToQuotesPolling()
subscribeToAppCurrencyChanges()
subscribeToMarketLoadingErrors()
subscribeToResultsShown()
loadHistory()
searchAnalyticsHelper.sendSearchScreenOpened(params.sourceParams)
}
fun loadMore() {
@ -126,11 +144,14 @@ internal class SearchModel @Inject constructor(
fun clearSearchHistory() {
modelScope.launch(dispatchers.default) {
clearSearchHistoryUseCase()
searchAnalyticsHelper.sendClearButtonClicked()
}
}
fun onTextHintClick(text: String) {
stateController.update(UpdateSearchBarQueryTransformer(text))
searchAnalyticsHelper.sendHintClicked(text)
searchAnalyticsHelper.sendSearchStarted()
}
fun onResultMarketTokenClick(item: MarketsListItemUM) {
@ -143,17 +164,40 @@ internal class SearchModel @Inject constructor(
)
saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input))
saveSearchQueryUseCase(stateController.value.searchBar.query)
searchAnalyticsHelper.sendMarketItemClicked(item.currencySymbol)
withContext(dispatchers.mainImmediate) {
searchMarketsListManager.getTokenById(item.id)?.let { found ->
params.onMarketTokenClick(found.toSerializableParam(), appCurrency)
}
}
}
}
fun onHistoryTokenClick(item: MarketsListItemUM) {
val tokenMarketParams = TokenMarketParams(
id = item.id,
name = item.name,
symbol = item.currencySymbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = item.price.fiatPrice,
h24Percent = null,
weekPercent = null,
monthPercent = null,
),
imageUrl = item.iconUrl,
)
searchAnalyticsHelper.sendRecentItemClicked(item.currencySymbol)
params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value)
}
private fun initCallbacks() {
stateController.update(object : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(
onQueryChange = ::onQueryChange,
onActiveChange = ::onActiveChange,
onClearClick = ::onClearClick,
onCancelClick = params.onBackClick,
),
)
}
@ -162,16 +206,41 @@ internal class SearchModel @Inject constructor(
private fun onQueryChange(query: String) {
stateController.update(UpdateSearchBarQueryTransformer(query))
}
private fun onActiveChange(isActive: Boolean) {
if (!isActive) params.onBackClick()
searchAnalyticsHelper.sendSearchStarted()
}
private fun onClearClick() {
stateController.update(UpdateSearchBarQueryTransformer(""))
}
private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) {
searchAnalyticsHelper.sendPortfolioItemClicked(entry.currencyStatus.currency.symbol)
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = entry.userWalletId,
currency = entry.currencyStatus.currency,
),
)
}
private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) {
searchAnalyticsHelper.sendGroupClicked(grouped.tokenSymbol)
bottomSheetNavigation.activate(
SearchBottomSheetRoute.TokenSelector(
entries = grouped.entries,
appCurrency = currentAppCurrency.value,
isBalanceHidden = isBalanceHidden.value,
onTokenSelected = ::onTokenSelectedFromGroup,
onDismiss = { bottomSheetNavigation.dismiss() },
),
)
}
private fun onTokenSelectedFromGroup(entry: UserAssetSearchEntry) {
bottomSheetNavigation.dismiss()
onSingleUserAssetClick(entry)
}
private fun subscribeToQueryChanges() {
stateController.uiState
.map { it.searchBar.query.trim() }
@ -200,20 +269,21 @@ internal class SearchModel @Inject constructor(
private fun subscribeToSearchResults(query: String) {
modelScope.launch {
getSearchResultsUseCase(query = query).collectLatest { searchResult ->
val userAssets = searchResult.userAssets.map { entry ->
UserAssetItemUM(
id = "${entry.userWalletId.stringValue}_${entry.accountId.value}" +
"_${entry.currencyStatus.currency.id.value}",
tokenIconUrl = entry.currencyStatus.currency.iconUrl,
tokenName = entry.currencyStatus.currency.name,
tokenSymbol = entry.currencyStatus.currency.symbol,
accountName = entry.accountName.toDisplayString(),
onClick = {
// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task.
},
)
}.toImmutableList()
combine(
getSearchResultsUseCase(query = query),
currentAppCurrency,
isBalanceHidden,
) { searchResult, appCurrency, balanceHidden ->
val converter = UserAssetSearchItemConverter(
appCurrency = appCurrency,
isBalanceHidden = balanceHidden,
onSingleClick = ::onSingleUserAssetClick,
onGroupedClick = ::onGroupedUserAssetClick,
)
searchResult.userAssets
.map(converter::convert)
.toImmutableList()
}.collectLatest { userAssets ->
stateController.update(UpdateUserAssetsTransformer(userAssets))
}
}.saveIn(searchResultsJob)
@ -235,6 +305,50 @@ internal class SearchModel @Inject constructor(
}.launchIn(modelScope)
}
private fun subscribeToMarketLoadingErrors() {
searchMarketsListManager.initialLoadingError
.onEach { throwable ->
val (code, message) = when (throwable) {
is ApiResponseError.HttpException ->
throwable.code.numericCode to throwable.message.orEmpty()
else -> null to throwable.message.orEmpty()
}
searchAnalyticsHelper.sendErrorMarketsData(code, message)
}
.launchIn(modelScope)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
private fun subscribeToResultsShown() {
stateController.uiState
.map { it.searchBar.query.trim() }
.distinctUntilChanged()
.flatMapLatest { query ->
if (query.isEmpty()) {
emptyFlow()
} else {
combine(
searchMarketsListManager.totalCount.filterNotNull(),
stateController.uiState.map { state ->
(state.content as? SearchContentUM.Results)?.userAssets?.size ?: 0
}.distinctUntilChanged(),
) { marketCount, userAssetsCount ->
marketCount to userAssetsCount
}
.debounce(RESULTS_SHOWN_DEBOUNCE_MS)
.take(1)
}
}
.onEach { (marketCount, userAssetsCount) ->
searchAnalyticsHelper.sendResultShown(
totalResultsCount = marketCount + userAssetsCount,
marketsResultsCount = marketCount,
userTokensResultsCount = userAssetsCount,
)
}
.launchIn(modelScope)
}
private fun subscribeToMarketUiItems() {
combine(
flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(),
@ -256,6 +370,7 @@ internal class SearchModel @Inject constructor(
)
}
.filterNotNull()
.distinctUntilChanged()
.onEach { snapshot ->
stateController.update(ApplySearchMarketBatchTransformer(snapshot))
}
@ -334,13 +449,6 @@ internal class SearchModel @Inject constructor(
}
}
private fun AccountName.toDisplayString(): String {
return when (this) {
is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize
is AccountName.Custom -> value
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {

View file

@ -0,0 +1,38 @@
package com.tangem.features.feed.model.search
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.feed.components.search.SearchTokenSelectorComponent
import com.tangem.features.feed.model.search.state.TokenSelectorStateController
import com.tangem.features.feed.model.search.state.transformers.BuildTokenSelectorSectionsTransformer
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@Stable
@ModelScoped
internal class SearchTokenSelectorModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val stateController: TokenSelectorStateController,
) : Model() {
private val params = paramsContainer.require<SearchTokenSelectorComponent.Params>()
val state: StateFlow<TokenSelectorContentUM>
get() = stateController.uiState
init {
stateController.update(
BuildTokenSelectorSectionsTransformer(
entries = params.entries,
appCurrency = params.appCurrency,
isBalanceHidden = params.isBalanceHidden,
onTokenSelected = params.onTokenSelected,
),
)
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.features.feed.model.search.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR
internal sealed class SearchAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Search", event = event, params = params) {
data class SearchScreenOpened(
private val screensSource: String,
) : SearchAnalyticsEvent(
event = "Search Screen Opened",
params = mapOf(SOURCE to screensSource),
)
class SearchStarted : SearchAnalyticsEvent(event = "Search Started")
data class ResultsShown(
private val totalResultsCount: Int,
private val marketsResultsCount: Int,
private val userTokensResultsCount: Int,
) : SearchAnalyticsEvent(
event = "Results Shown",
params = mapOf(
"Total Results" to totalResultsCount.toString(),
"User Tokens Count" to userTokensResultsCount.toString(),
"Market Tokens Count" to marketsResultsCount.toString(),
),
)
data class ErrorMarketsData(
private val code: Int?,
private val message: String,
) : SearchAnalyticsEvent(
event = "Error - Markets Data",
params = mapOf(
ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(),
ERROR_MESSAGE to message,
),
)
data class PortfolioItemClicked(
private val tokenSymbol: String,
) : SearchAnalyticsEvent(
event = "Portfolio Item Clicked",
params = mapOf(TOKEN_PARAM to tokenSymbol),
)
data class HintClicked(
private val hint: String,
) : SearchAnalyticsEvent(
event = "Hint Clicked",
params = mapOf("Text" to hint),
)
data class RecentItemClicked(
private val tokenSymbol: String,
) : SearchAnalyticsEvent(
event = "Recent Item Clicked",
params = mapOf(TOKEN_PARAM to tokenSymbol),
)
data class MarketItemClicked(
private val tokenSymbol: String,
) : SearchAnalyticsEvent(
event = "Market Item Clicked",
params = mapOf(TOKEN_PARAM to tokenSymbol),
)
data class GroupClicked(
private val tokenSymbol: String,
) : SearchAnalyticsEvent(
event = "Group Clicked",
params = mapOf(TOKEN_PARAM to tokenSymbol),
)
class ButtonClearHistoryClick : SearchAnalyticsEvent(event = "Button - Clear History")
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.feed.model.search.analytics
import com.tangem.core.analytics.api.AnalyticsEventHandler
import javax.inject.Inject
class SearchAnalyticsHelper @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
) {
private var isSearchStartedWasSent = false
fun sendSearchScreenOpened(source: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.SearchScreenOpened(source))
}
fun sendMarketItemClicked(tokenSymbol: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.MarketItemClicked(tokenSymbol))
}
fun sendClearButtonClicked() {
analyticsEventHandler.send(SearchAnalyticsEvent.ButtonClearHistoryClick())
}
fun sendHintClicked(text: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.HintClicked(text))
}
fun sendRecentItemClicked(tokenSymbol: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.RecentItemClicked(tokenSymbol))
}
fun sendSearchStarted() {
if (isSearchStartedWasSent) return
analyticsEventHandler.send(SearchAnalyticsEvent.SearchStarted())
isSearchStartedWasSent = true
}
fun sendPortfolioItemClicked(tokenSymbol: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.PortfolioItemClicked(tokenSymbol))
}
fun sendGroupClicked(tokenSymbol: String) {
analyticsEventHandler.send(SearchAnalyticsEvent.GroupClicked(tokenSymbol))
}
fun sendResultShown(totalResultsCount: Int, marketsResultsCount: Int, userTokensResultsCount: Int) {
analyticsEventHandler.send(
SearchAnalyticsEvent.ResultsShown(
totalResultsCount = totalResultsCount,
marketsResultsCount = marketsResultsCount,
userTokensResultsCount = userTokensResultsCount,
),
)
}
fun sendErrorMarketsData(code: Int?, message: String) {
analyticsEventHandler.send(
SearchAnalyticsEvent.ErrorMarketsData(
code = code,
message = message,
),
)
}
}

View file

@ -0,0 +1,176 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.stringReference
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.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
internal class UserAssetSearchItemConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onSingleClick: (UserAssetSearchEntry) -> Unit,
private val onGroupedClick: (UserAssetSearchItem.Grouped) -> Unit,
) : Converter<UserAssetSearchItem, UserAssetItemUM> {
override fun convert(value: UserAssetSearchItem): UserAssetItemUM {
return when (value) {
is UserAssetSearchItem.Single -> convertSingle(value.entry)
is UserAssetSearchItem.Grouped -> convertGrouped(value)
}
}
private fun convertSingle(entry: UserAssetSearchEntry): UserAssetItemUM.Single {
val currency = entry.currencyStatus.currency
val value = entry.currencyStatus.value
return UserAssetItemUM.Single(
id = "${entry.userWalletId.stringValue}_${entry.accountId.value}_${currency.id.value}",
icon = TangemIconUM.Currency(
currencyIconState = CryptoCurrencyToIconStateConverter().convert(entry.currencyStatus),
),
tokenName = currency.name,
tokenSymbol = currency.symbol,
fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) },
priceChangeState = when (value) {
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.NoAmount,
-> PriceChangeState.Unknown
else -> PriceChangeState.Content(
type = PriceChangeType.fromBigDecimal(value.priceChange.orZero()),
valueInPercent = value.priceChange.format { percent() },
)
},
balanceState = convertSingleBalanceState(value, currency.symbol, currency.decimals),
isBalanceHidden = isBalanceHidden,
onClick = { onSingleClick(entry) },
networkName = entry.currencyStatus.currency.network.name,
)
}
private fun convertSingleBalanceState(
value: CryptoCurrencyStatus.Value,
symbol: String,
decimals: Int,
): BalanceDisplayState {
return when {
value is CryptoCurrencyStatus.Loading && value.amount != null ->
BalanceDisplayState.Flickering(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol,
) ?: stringReference(StringsSigns.DASH_SIGN),
)
value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading
value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable
value.isError && value.amount != null ->
BalanceDisplayState.Stale(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol,
) ?: stringReference(StringsSigns.DASH_SIGN),
)
value.isError -> BalanceDisplayState.Unreachable
else -> BalanceDisplayState.Loaded(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol,
) ?: stringReference(StringsSigns.DASH_SIGN),
)
}
}
private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped {
val firstCurrency = item.entries.first().currencyStatus.currency
val entryCurrencyStatus = item.entries.first().currencyStatus
return UserAssetItemUM.Grouped(
id = "grouped_${item.tokenName}_${item.tokenSymbol}",
icon = TangemIconUM.Currency(
currencyIconState = CurrencyIconState.CoinIcon(
url = entryCurrencyStatus.currency.iconUrl,
fallbackResId = entryCurrencyStatus.currency.networkIconResId,
isGrayscale = entryCurrencyStatus.currency.network.isTestnet || entryCurrencyStatus.value.isError,
shouldShowCustomBadge = entryCurrencyStatus.currency.isCustom,
),
),
tokenName = item.tokenName,
tokenSymbol = item.tokenSymbol,
tokensCount = item.entries.size,
balanceState = convertGroupedBalanceState(item.entries, firstCurrency.symbol, firstCurrency.decimals),
isBalanceHidden = isBalanceHidden,
onClick = { onGroupedClick(item) },
)
}
private fun convertGroupedBalanceState(
entries: List<UserAssetSearchEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState {
val hasAnyLoading = entries.any { it.currencyStatus.value is CryptoCurrencyStatus.Loading }
val hasAnyError = entries.any { it.currencyStatus.value.isError }
val hasAnyAmount = entries.any { it.currencyStatus.value.amount != null }
val isAllError = entries.all { it.currencyStatus.value.isError }
val balance = when {
hasAnyLoading && !hasAnyAmount -> BalanceDisplayState.Loading
hasAnyLoading && hasAnyAmount -> computeGroupBalanceFlickering(entries, symbol, decimals)
isAllError -> BalanceDisplayState.Unreachable
hasAnyError && entries.size == 1 && !hasAnyAmount -> BalanceDisplayState.Unreachable
hasAnyError && entries.size > 1 -> computeGroupBalance(entries, symbol, decimals)
else -> computeGroupBalance(entries, symbol, decimals)
}
return balance
}
private fun computeGroupBalance(
entries: List<UserAssetSearchEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState.Loaded {
val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO }
return BalanceDisplayState.Loaded(
cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)),
fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol),
)
}
private fun computeGroupBalanceFlickering(
entries: List<UserAssetSearchEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState.Flickering {
val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO }
return BalanceDisplayState.Flickering(
cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)),
fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol),
)
}
private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String {
return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.feed.model.search.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.features.feed.model.search.state.transformers.TokenSelectorUMTransformer
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class TokenSelectorStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<TokenSelectorContentUM> = MutableStateFlow(
value = TokenSelectorContentUM(sections = persistentListOf()),
)
val uiState: StateFlow<TokenSelectorContentUM> get() = mutableUiState.asStateFlow()
fun update(transformer: TokenSelectorUMTransformer) {
mutableUiState.update(function = transformer::transform)
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.common.ui.account.toUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.AccountHeaderData
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import kotlinx.collections.immutable.toImmutableList
internal class BuildTokenSelectorSectionsTransformer(
private val entries: List<UserAssetSearchEntry>,
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenSelected: (UserAssetSearchEntry) -> Unit,
) : TokenSelectorUMTransformer {
private val entryConverter = TokenSelectorEntryConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenSelected = onTokenSelected,
)
override fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM {
return TokenSelectorContentUM(sections = buildSections().toImmutableList())
}
private fun buildSections(): List<TokenSelectorSectionUM> {
val sections = mutableListOf<TokenSelectorSectionUM>()
val byWallet = entries.groupBy { it.userWalletId }
val shouldShowWalletHeaders = byWallet.size > 1
for ((_, walletEntries) in byWallet) {
if (shouldShowWalletHeaders) {
sections.add(
TokenSelectorSectionUM.WalletHeader(
walletName = walletEntries.first().userWalletName,
),
)
}
val byAccount = walletEntries.groupBy { it.accountId }
val shouldShowAccountHeaders = byAccount.size > 1
for ((_, accountEntries) in byAccount) {
val singles = entryConverter.convertList(accountEntries).toImmutableList()
val accountHeader = if (shouldShowAccountHeaders) {
val firstEntry = accountEntries.first()
AccountHeaderData(
accountName = firstEntry.accountName.toUM().value,
cryptoPortfolioIcon = firstEntry.accountIcon,
)
} else {
null
}
sections.add(
TokenSelectorSectionUM.TokenGroup(
accountHeader = accountHeader,
items = singles,
),
)
}
}
return sections
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
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.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import java.math.BigDecimal
internal class TokenSelectorEntryConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenSelected: (UserAssetSearchEntry) -> Unit,
) : Converter<UserAssetSearchEntry, UserAssetItemUM.Single> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: UserAssetSearchEntry): UserAssetItemUM.Single {
val currency = value.currencyStatus.currency
val currencyValue = value.currencyStatus.value
return UserAssetItemUM.Single(
id = "${value.userWalletId.stringValue}_${value.accountId.value}_${currency.id.value}",
icon = TangemIconUM.Currency(
currencyIconState = iconConverter.convert(value.currencyStatus),
),
tokenName = currency.name,
tokenSymbol = currency.symbol,
fiatRate = currencyValue.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) },
priceChangeState = when (currencyValue) {
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.NoAmount,
-> PriceChangeState.Unknown
else -> PriceChangeState.Content(
type = PriceChangeType.fromBigDecimal(currencyValue.priceChange.orZero()),
valueInPercent = currencyValue.priceChange.format { percent() },
)
},
balanceState = convertBalanceState(currencyValue, currency.symbol, currency.decimals),
isBalanceHidden = isBalanceHidden,
onClick = { onTokenSelected(value) },
networkName = value.currencyStatus.currency.network.name,
)
}
private fun convertBalanceState(
value: CryptoCurrencyStatus.Value,
symbol: String,
decimals: Int,
): BalanceDisplayState {
return when {
value is CryptoCurrencyStatus.Loading && value.amount != null ->
BalanceDisplayState.Flickering(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading
value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable
value.isError && value.amount != null ->
BalanceDisplayState.Stale(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
value.isError -> BalanceDisplayState.Unreachable
else -> BalanceDisplayState.Loaded(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
}
}
private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String {
return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN
}
}

View file

@ -0,0 +1,7 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
internal interface TokenSelectorUMTransformer {
fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM
}

View file

@ -0,0 +1,99 @@
package com.tangem.features.feed.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
private const val MIN_STACKED_ICON_COUNT = 1
private const val MAX_STACKED_ICON_COUNT = 3
private const val FIRST_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER = 1
private const val SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER = 2
private const val MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER = 2
private const val FIRST_BACK_LAYER_ICON_ALPHA = 0.4f
private const val SECOND_BACK_LAYER_ICON_ALPHA = 0.2f
@Composable
fun LayeringIcons(
tangemIconUM: TangemIconUM,
modifier: Modifier = Modifier,
count: Int = MIN_STACKED_ICON_COUNT,
layerHorizontalShift: Dp = TangemTheme.dimens2.x1,
iconSize: Dp = TangemTheme.dimens2.x10,
) {
require(count > 0)
val stackTrailingWidth = layerHorizontalShift * SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER
Box(
modifier = modifier.size(
width = iconSize + stackTrailingWidth,
height = iconSize,
),
) {
val baseIconModifier = Modifier
.align(Alignment.TopStart)
.size(iconSize)
if (count >= MAX_STACKED_ICON_COUNT) {
TangemIcon(
tangemIconUM = tangemIconUM,
modifier = baseIconModifier
.offset(x = layerHorizontalShift * SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER)
.alpha(SECOND_BACK_LAYER_ICON_ALPHA),
)
}
if (count >= MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER) {
TangemIcon(
tangemIconUM = tangemIconUM,
modifier = baseIconModifier
.offset(x = layerHorizontalShift * FIRST_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER)
.alpha(FIRST_BACK_LAYER_ICON_ALPHA),
)
}
TangemIcon(
tangemIconUM = tangemIconUM,
modifier = baseIconModifier,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun LayeringIconsPreview() {
val previewCurrencyIcon = TangemIconUM.Currency(
currencyIconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = null,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
)
TangemThemePreview {
Column(horizontalAlignment = Alignment.End) {
LayeringIcons(count = MIN_STACKED_ICON_COUNT, tangemIconUM = previewCurrencyIcon)
LayeringIcons(
count = MAX_STACKED_ICON_COUNT,
tangemIconUM = previewCurrencyIcon,
)
LayeringIcons(
count = MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER,
tangemIconUM = previewCurrencyIcon,
)
}
}
}

View file

@ -106,6 +106,13 @@ private fun Content(
lazyListState = lazyListState,
onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange,
)
EventEffect(state.scrollToSection) { targetKey ->
val targetIndex = lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == targetKey }?.index
if (targetIndex != null) {
lazyListState.animateScrollToItem(targetIndex)
}
}
var bottomSpacing by remember { mutableStateOf(0.dp) }
Box(

View file

@ -76,6 +76,8 @@ private fun LazyListScope.tokenMarketDetailsBodyV1(
if (relatedNews.articles.isNotEmpty()) {
relatedNews(relatedNews)
} else {
sectionStub(RelatedNews.SECTION_KEY)
}
aboutCoinHeader()
@ -91,6 +93,11 @@ private fun LazyListScope.tokenMarketDetailsBodyV1(
}
}
// Empty item with a key so that deeplink scroll-to-section can target it before the real content is composed
private fun LazyListScope.sectionStub(key: String) {
item(key) { }
}
@Suppress("CanBeNonNullable")
private fun LazyListScope.tokenMarketDetailsBodyV2(state: MarketsTokenDetailsUM.Body, relatedNews: RelatedNews) {
when (state) {
@ -244,6 +251,8 @@ internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.Informa
if (relatedNews.articles.isNotEmpty()) {
relatedNews(relatedNews)
} else {
sectionStub(RelatedNews.SECTION_KEY)
}
if (state.securityScore != null) {
@ -324,7 +333,7 @@ private fun LazyListScope.loadingInfoBlocksV2() {
}
private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
item("related-news") {
item(RelatedNews.SECTION_KEY) {
Column(
modifier = Modifier
.fillMaxWidth()

View file

@ -5,6 +5,7 @@ import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
@ -30,6 +31,7 @@ internal data class MarketsTokenDetailsUM(
val onShouldShowPriceSubtitleChange: (Boolean) -> Unit,
val relatedNews: RelatedNews,
val onShareClick: () -> Unit,
val scrollToSection: StateEvent<String> = consumedEvent(),
) {
data class ChartState(
@ -80,5 +82,9 @@ internal data class MarketsTokenDetailsUM(
val onArticledClicked: (id: Int) -> Unit,
val onFirstVisible: () -> Unit,
val onScroll: () -> Unit,
)
) {
companion object {
const val SECTION_KEY = "related-news"
}
}
}

View file

@ -8,13 +8,17 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider
import com.tangem.core.ui.components.*
@ -26,9 +30,9 @@ import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@ -104,33 +108,43 @@ internal fun TopBarWithSearch(
@Composable
internal fun MarketsList(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.drawBehind { drawRect(background) },
) {
Content(state = state, contentPadding = contentPadding)
// should use here new overrided haze state cause on level upper already applied
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.drawBehind { drawRect(background) },
) {
Content(state = state, contentPadding = contentPadding)
}
MarketsListSortByBottomSheet(config = state.sortByBottomSheet)
KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown)
}
MarketsListSortByBottomSheet(config = state.sortByBottomSheet)
KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown)
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) {
val isRedesignEnabled = LocalRedesignEnabled.current
val hazeState = rememberHazeState()
val strokeColor = if (isRedesignEnabled) {
TangemTheme.colors2.border.neutral.primary
if (LocalRedesignEnabled.current) {
ContentV2(
contentPadding = contentPadding,
state = state,
)
} else {
TangemTheme.colors.stroke.primary
ContentV1(
contentPadding = contentPadding,
state = state,
modifier = modifier,
)
}
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.ContentV1(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) {
val strokeColor = TangemTheme.colors.stroke.primary
val scrolledState = remember { mutableStateOf(false) }
Column(modifier.padding(horizontal = TangemTheme.dimens.size16)) {
SpacerH(contentPadding.calculateTopPadding())
AnimatedVisibility(
@ -151,19 +165,12 @@ private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsLis
Column {
AnimatedVisibility(!state.isInSearchMode && !state.marketsSearchBar.shouldAlwaysShowSearchBar) {
Options(
modifier = Modifier.padding(
bottom = if (isRedesignEnabled) {
TangemTheme.dimens2.x2
} else {
TangemTheme.dimens.spacing12
},
),
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
sortByTypeUM = state.selectedSortBy,
trendInterval = state.selectedInterval,
onIntervalClick = state.onIntervalClick,
onSortByClick = state.onSortByButtonClick,
sortMenuUM = state.sortByMenuUM,
hazeState = hazeState,
)
}
}
@ -186,24 +193,57 @@ private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsLis
},
)
ItemsList(
modifier = Modifier.conditionalCompose(
condition = isRedesignEnabled,
modifier = {
hazeSourceTangem(zIndex = 0f, state = hazeState)
},
),
scrolledState = scrolledState,
isInSearchMode = state.isInSearchMode,
state = state.list,
)
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.ContentV2(contentPadding: PaddingValues, state: MarketsListUM) {
val scrolledState = remember { mutableStateOf(false) }
var optionsHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Box(modifier = Modifier.fillMaxSize()) {
ItemsList(
topContentPadding = contentPadding.calculateTopPadding() + optionsHeight,
modifier = Modifier
.align(Alignment.TopStart)
.hazeSourceTangem(zIndex = 1f),
scrolledState = scrolledState,
isInSearchMode = state.isInSearchMode,
state = state.list,
)
Options(
modifier = Modifier
.align(Alignment.TopStart)
.padding(bottom = TangemTheme.dimens2.x4, top = contentPadding.calculateTopPadding())
.padding(horizontal = TangemTheme.dimens2.x4)
.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
optionsHeight = coordinates.size.height.toDp()
}
}
},
sortByTypeUM = state.selectedSortBy,
trendInterval = state.selectedInterval,
onIntervalClick = state.onIntervalClick,
onSortByClick = state.onSortByButtonClick,
sortMenuUM = state.sortByMenuUM,
)
}
}
@Composable
private fun ItemsList(
scrolledState: MutableState<Boolean>,
isInSearchMode: Boolean,
state: ListUM,
modifier: Modifier = Modifier,
topContentPadding: Dp = 0.dp,
) {
val searchLazyListState = rememberLazyListState()
val mainLazyListState = rememberLazyListState()
@ -229,6 +269,7 @@ private fun ItemsList(
}
MarketsListLazyColumn(
topContentPadding = topContentPadding,
modifier = modifier,
state = state,
isInSearchMode = isInSearchMode,

View file

@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
@ -43,6 +44,7 @@ internal fun MarketsListLazyColumn(
isInSearchMode: Boolean,
lazyListState: LazyListState,
modifier: Modifier = Modifier,
topContentPadding: Dp = 0.dp,
) {
val isRedesignEnabled = LocalRedesignEnabled.current
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
@ -66,7 +68,7 @@ internal fun MarketsListLazyColumn(
LazyColumn(
modifier = modifier,
state = rememberLazyListState(),
contentPadding = PaddingValues(bottom = bottomBarHeight),
contentPadding = PaddingValues(bottom = bottomBarHeight, top = topContentPadding),
userScrollEnabled = false,
) {
items(count = 100, key = { it }) {
@ -77,7 +79,7 @@ internal fun MarketsListLazyColumn(
LazyColumn(
modifier = modifier.testTag(MarketsTestTags.TOKENS_LIST),
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight),
contentPadding = PaddingValues(bottom = bottomBarHeight, top = topContentPadding),
userScrollEnabled = true,
) {
// ATTENTION! There should be no elements with a string key value except MarketsListItem!

View file

@ -1,5 +1,6 @@
package com.tangem.features.feed.ui.market.list.components
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
@ -19,13 +20,14 @@ import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByMenuUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeProgressive
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.ds.button.TangemButtonIconPosition as RedesignTangemButtonIconPosition
@ -35,7 +37,6 @@ internal fun Options(
sortMenuUM: SortByMenuUM,
sortByTypeUM: SortByTypeUM,
trendInterval: MarketsListUM.TrendInterval,
hazeState: HazeState,
onSortByClick: () -> Unit,
onIntervalClick: (MarketsListUM.TrendInterval) -> Unit,
modifier: Modifier = Modifier,
@ -46,7 +47,6 @@ internal fun Options(
trendInterval = trendInterval,
onIntervalClick = onIntervalClick,
modifier = modifier,
hazeState = hazeState,
)
} else {
OptionsV1(
@ -116,11 +116,11 @@ private fun OptionsV1(
private fun OptionsV2(
sortMenuUM: SortByMenuUM,
trendInterval: MarketsListUM.TrendInterval,
hazeState: HazeState,
onIntervalClick: (MarketsListUM.TrendInterval) -> Unit,
modifier: Modifier = Modifier,
) {
var isShowDropdownMenu by rememberSaveable { mutableStateOf(false) }
val background = LocalMainBottomSheetColor.current.value
val segmentItems = remember {
persistentListOf(
@ -139,42 +139,55 @@ private fun OptionsV2(
)
}
Row(
Box(
modifier = modifier
.height(IntrinsicSize.Max)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
.fillMaxWidth()
.wrapContentHeight(align = Alignment.Top),
) {
PrimaryInverseTangemButton(
onClick = {
isShowDropdownMenu = true
},
iconPosition = RedesignTangemButtonIconPosition.End,
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_chewron_down_20,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
text = sortMenuUM.selectedOption.text,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Max)
.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .2f,
endIntensity = 0f,
easing = EaseOut,
preferPerformance = true,
)
backgroundColor = background
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
PrimaryInverseTangemButton(
onClick = { isShowDropdownMenu = true },
iconPosition = RedesignTangemButtonIconPosition.End,
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_chewron_down_20,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
text = sortMenuUM.selectedOption.text,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
)
TangemSegmentedPicker(
items = segmentItems,
initialSelectedItem = segmentItems.firstOrNull { it.id == trendInterval.name },
isFixed = false,
isAltSurface = true,
minSegmentWidth = 54.dp,
onClick = { segment -> onIntervalClick(MarketsListUM.TrendInterval.valueOf(segment.id)) },
TangemSegmentedPicker(
items = segmentItems,
initialSelectedItem = segmentItems.firstOrNull { it.id == trendInterval.name },
isFixed = false,
isAltSurface = true,
minSegmentWidth = 54.dp,
onClick = { segment -> onIntervalClick(MarketsListUM.TrendInterval.valueOf(segment.id)) },
)
}
SortByMenu(
sortMenuUM = sortMenuUM,
showDropdownMenu = isShowDropdownMenu,
onDropdownDismiss = { isShowDropdownMenu = false },
modifier = Modifier
.align(Alignment.TopStart)
.hazeEffectTangem { blurRadius = 10.dp },
)
}
SortByMenu(
sortMenuUM = sortMenuUM,
showDropdownMenu = isShowDropdownMenu,
onDropdownDismiss = { isShowDropdownMenu = false },
modifier = Modifier.hazeEffectTangem(hazeState) {
blurRadius = 10.dp
},
)
}

View file

@ -1,37 +1,54 @@
package com.tangem.features.feed.ui.news.list
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.chip.Chip
import com.tangem.core.ui.components.chip.entity.ChipUM
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.ds.tabs.TangemTab
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.*
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.news.list.components.NewsListLazyColumn
import com.tangem.features.feed.ui.news.list.state.NewsListState
import com.tangem.features.feed.ui.news.list.state.NewsListUM
import dev.chrisbanes.haze.HazeProgressive
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
@Composable
internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, modifier: Modifier = Modifier) {
if (LocalRedesignEnabled.current) {
NewsListContentV2(
contentPadding = contentPadding,
state = state,
)
} else {
NewsListContentV1(
contentPadding = contentPadding,
state = state,
modifier = modifier,
)
}
}
@Composable
internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
val isRedesignEnabled = LocalRedesignEnabled.current
val lazyListState = rememberLazyListState()
Column(
@ -48,17 +65,7 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m
items = state.filters,
key = { it.id },
) { filter ->
if (isRedesignEnabled) {
TangemTab(
text = filter.text,
isChecked = filter.isSelected,
onCheckedChange = {
filter.onClick()
},
)
} else {
Chip(state = filter)
}
Chip(state = filter)
}
}
@ -73,6 +80,67 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m
}
}
@Composable
internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM) {
val background = LocalMainBottomSheetColor.current.value
val lazyListState = rememberLazyListState()
var chipsHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Box(
modifier = Modifier
.fillMaxSize()
.background(background),
) {
NewsListLazyColumn(
topContentPadding = contentPadding.calculateTopPadding() + 16.dp + chipsHeight,
modifier = Modifier
.hazeSourceTangem(zIndex = 0f)
.align(Alignment.TopStart),
newsListState = state.newsListState,
listOfArticles = state.listOfArticles,
lazyListState = lazyListState,
onArticleClick = state.onArticleClick,
)
LazyRow(
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = contentPadding.calculateTopPadding(), bottom = TangemTheme.dimens2.x4)
.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
chipsHeight = coordinates.size.height.toDp()
}
}
}
.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .2f,
endIntensity = 0f,
easing = EaseOut,
preferPerformance = true,
)
backgroundColor = background
},
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = state.filters,
key = { it.id },
) { filter ->
TangemTab(
text = filter.text,
isChecked = filter.isSelected,
onCheckedChange = {
filter.onClick()
},
)
}
}
}
}
@Suppress("LongMethod")
@Preview(showBackground = true)
@Composable

View file

@ -15,15 +15,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle
import com.tangem.features.feed.ui.news.list.state.NewsListState
import kotlinx.collections.immutable.ImmutableList
@ -35,6 +36,8 @@ internal fun NewsListLazyColumn(
newsListState: NewsListState,
lazyListState: LazyListState,
onArticleClick: (Int) -> Unit,
modifier: Modifier = Modifier,
topContentPadding: Dp = 0.dp,
) {
val screenState by remember(listOfArticles, newsListState) {
derivedStateOf {
@ -48,6 +51,7 @@ internal fun NewsListLazyColumn(
}
AnimatedContent(
modifier = modifier,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
@ -57,6 +61,7 @@ internal fun NewsListLazyColumn(
when (state) {
NewsListScreenState.Content -> {
Content(
topContentPadding = topContentPadding,
listOfArticles = listOfArticles,
newsListState = newsListState,
lazyListState = lazyListState,
@ -66,7 +71,7 @@ internal fun NewsListLazyColumn(
NewsListScreenState.InitialLoading -> {
LazyColumn(
state = rememberLazyListState(),
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp),
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp, top = topContentPadding),
userScrollEnabled = false,
) {
items(
@ -100,11 +105,12 @@ private fun Content(
lazyListState: LazyListState,
onArticleClick: (Int) -> Unit,
modifier: Modifier = Modifier,
topContentPadding: Dp = 0.dp,
) {
LazyColumn(
modifier = modifier,
state = lazyListState,
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp),
contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp, top = topContentPadding),
userScrollEnabled = true,
) {
items(

View file

@ -1,22 +1,18 @@
package com.tangem.features.feed.ui.search
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
@ -31,17 +27,20 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem
import com.tangem.features.feed.ui.search.components.SingleUserAssetItem
import com.tangem.features.feed.ui.search.state.*
import kotlinx.collections.immutable.ImmutableList
private const val PLACEHOLDER_COUNT = 10
private const val LOAD_MORE_THRESHOLD = 5
private const val USER_ASSETS_LIMIT = 3
@Composable
internal fun SearchContent(
@ -54,6 +53,23 @@ internal fun SearchContent(
val lazyListState = rememberLazyListState()
val background = LocalMainBottomSheetColor.current.value
val contentStructureKey = when (content) {
is SearchContentUM.InitialEmpty -> "empty"
is SearchContentUM.History -> "history"
is SearchContentUM.Results -> "results_${content.userAssets.isNotEmpty()}"
}
LaunchedEffect(contentStructureKey) {
lazyListState.scrollToItem(0)
}
var isUserAssetsExpanded by rememberSaveable { mutableStateOf(false) }
val shouldShowUserAssetsPortfolio = content is SearchContentUM.Results && content.userAssets.isNotEmpty()
LaunchedEffect(shouldShowUserAssetsPortfolio) {
if (!shouldShowUserAssetsPortfolio) {
isUserAssetsExpanded = false
}
}
LazyColumn(
state = lazyListState,
modifier = modifier
@ -72,9 +88,12 @@ internal fun SearchContent(
history = content,
onClearAllClick = searchCallbacks.onClearHintsClick,
onHintClick = searchCallbacks.onTextHintClick,
onHistoryTokenClick = searchCallbacks.onHistoryTokenClick,
)
is SearchContentUM.Results -> searchResultsItems(
results = content,
isUserAssetsExpanded = isUserAssetsExpanded,
onUserAssetsExpandedChange = { isUserAssetsExpanded = it },
onResultMarketTokenClick = searchCallbacks.onResultMarketTokenClick,
)
}
@ -98,6 +117,7 @@ private fun LazyListScope.searchHistoryItems(
history: SearchContentUM.History,
onClearAllClick: (() -> Unit),
onHintClick: (String) -> Unit,
onHistoryTokenClick: (MarketsListItemUM) -> Unit,
) {
if (!history.textHints.isEmpty() || !history.recentTokens.isEmpty()) {
item(key = "recents") {
@ -107,15 +127,20 @@ private fun LazyListScope.searchHistoryItems(
)
}
}
items(
itemsIndexed(
items = history.textHints,
key = { "hint_${it.text}" },
) { hint ->
key = { _, item -> "hint_${item.text}" },
) { index, hint ->
TextHintItem(hint = hint, onHintClick = { onHintClick(hint.text) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2),
color = TangemTheme.colors2.border.neutral.primary,
)
if (index < history.textHints.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2),
color = TangemTheme.colors2.border.neutral.primary,
)
}
}
item {
SpacerH(TangemTheme.dimens2.x2)
}
items(
items = history.recentTokens,
@ -129,25 +154,26 @@ private fun LazyListScope.searchHistoryItems(
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
model = token,
onClick = {}, // TODO in [REDACTED_TASK_KEY]
onClick = { onHistoryTokenClick(token) },
)
}
}
private fun LazyListScope.searchResultsItems(
results: SearchContentUM.Results,
isUserAssetsExpanded: Boolean,
onUserAssetsExpandedChange: (Boolean) -> Unit,
onResultMarketTokenClick: (MarketsListItemUM) -> Unit,
) {
if (results.userAssets.isNotEmpty()) {
item(key = "header_portfolio") {
SectionHeader(title = stringResourceSafe(R.string.markets_search_portfolio_header))
}
items(
items = results.userAssets,
key = { it.id },
) { asset ->
UserAssetItem(asset)
}
userAssetsPortfolioItems(
assets = results.userAssets,
expanded = isUserAssetsExpanded,
onExpandedChange = onUserAssetsExpandedChange,
)
}
when (val market = results.marketTokens) {
@ -164,6 +190,42 @@ private fun LazyListScope.searchResultsItems(
}
}
private fun LazyListScope.userAssetsPortfolioItems(
assets: ImmutableList<UserAssetItemUM>,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
) {
val shouldShowToggle = assets.size > USER_ASSETS_LIMIT
val visibleCount = if (shouldShowToggle && !expanded) USER_ASSETS_LIMIT else assets.size
items(
count = visibleCount,
key = { index -> "user_asset_${assets[index].id}" },
) { index ->
Column(
modifier = Modifier.animateItem(
fadeInSpec = tween(durationMillis = 300),
fadeOutSpec = tween(durationMillis = 250),
),
) {
UserAssetItem(assets[index])
SpacerH(TangemTheme.dimens2.x2)
}
}
if (shouldShowToggle) {
item(key = "user_assets_show_toggle") {
ShowAllUserAssetsButton(
modifier = Modifier.animateItem(
fadeInSpec = tween(durationMillis = 300),
fadeOutSpec = tween(durationMillis = 250),
),
isExpanded = expanded,
onClick = { onExpandedChange(!expanded) },
)
}
}
}
private fun LazyListScope.marketSearchResultItems(
market: MarketSearchResultUM.Content,
hasUserAssetsSection: Boolean,
@ -223,7 +285,7 @@ private fun LazyListScope.marketSearchResultNotFoundItem() {
) {
Text(
text = stringResourceSafe(R.string.common_no_results),
style = TangemTheme.typography2.bodyRegular14,
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.tertiary,
)
}
@ -264,37 +326,44 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) {
}
}
// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task.
@Composable
private fun UserAssetItem(asset: UserAssetItemUM) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = asset.onClick)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TangemIcon(
tangemIconUM = TangemIconUM.Url(asset.tokenIconUrl, fallbackRes = R.drawable.ic_custom_token_44),
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = asset.tokenName,
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
text = "${asset.tokenSymbol} · ${asset.accountName}",
style = TangemTheme.typography2.captionRegular13,
color = TangemTheme.colors2.text.neutral.tertiary,
maxLines = 1,
)
when (asset) {
is UserAssetItemUM.Single -> Box(
modifier = Modifier.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
) {
SingleUserAssetItem(item = asset, shouldUsePriceBlock = true)
}
is UserAssetItemUM.Grouped -> GroupedUserAssetItem(item = asset)
}
}
@Composable
private fun ShowAllUserAssetsButton(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
TangemButton(
buttonUM = TangemButtonUM(
text = if (isExpanded) {
resourceReference(R.string.feed_search_show_less_user_assets)
} else {
resourceReference(R.string.feed_search_show_all_user_assets)
},
tangemIconUM = TangemIconUM.Icon(
iconRes = if (isExpanded) R.drawable.ic_chewron_up_20 else R.drawable.ic_chewron_down_20,
),
iconPosition = TangemButtonIconPosition.End,
onClick = onClick,
type = TangemButtonType.Secondary,
size = TangemButtonSize.X7,
shape = TangemButtonShape.Rounded,
),
)
}
}
@ -314,7 +383,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod
) {
Text(
text = stringResourceSafe(R.string.markets_search_see_tokens_under_100k),
style = TangemTheme.typography2.bodyRegular14,
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.secondary,
)
TangemButton(

View file

@ -0,0 +1,175 @@
package com.tangem.features.feed.ui.search.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.flicker
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.utils.StringsSigns
@Composable
internal fun BalanceColumn(
balanceState: BalanceDisplayState,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
if (isBalanceHidden) {
HiddenBalance(modifier)
return
}
when (balanceState) {
is BalanceDisplayState.Loading -> LoadingBalanceColumn(modifier)
is BalanceDisplayState.Flickering -> FlickeringBalanceColumn(balanceState, modifier)
is BalanceDisplayState.Stale -> StaleBalanceColumn(balanceState, modifier)
is BalanceDisplayState.Unreachable -> UnreachableBalanceColumn(modifier)
is BalanceDisplayState.Loaded -> LoadedBalanceColumn(balanceState, modifier)
}
}
@Composable
private fun BalanceColumnLayout(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = content,
)
}
@Composable
private fun LoadingBalanceColumn(modifier: Modifier = Modifier) {
BalanceColumnLayout(modifier) {
RectangleShimmer(modifier = Modifier.size(width = 108.dp, height = 20.dp), radius = 20.dp)
RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 16.dp), radius = 20.dp)
}
}
@Composable
private fun FlickeringBalanceColumn(state: BalanceDisplayState.Flickering, modifier: Modifier = Modifier) {
val flickerModifier = Modifier.flicker(isFlickering = true)
BalanceColumnLayout(modifier) {
Text(
modifier = flickerModifier,
text = state.fiatBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
modifier = flickerModifier,
text = state.cryptoBalance.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun StaleBalanceColumn(state: BalanceDisplayState.Stale, modifier: Modifier = Modifier) {
BalanceColumnLayout(modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
modifier = Modifier.size(16.dp),
painter = painterResource(id = R.drawable.ic_error_sync_24),
contentDescription = null,
tint = TangemTheme.colors2.markers.iconGray,
)
SpacerW(TangemTheme.dimens2.x0_5)
Text(
text = state.fiatBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = state.cryptoBalance.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun UnreachableBalanceColumn(modifier: Modifier = Modifier) {
BalanceColumnLayout(modifier) {
Text(
text = StringsSigns.DASH_SIGN,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_unreachable),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.status.attention,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerW(TangemTheme.dimens2.x0_5)
Icon(
modifier = Modifier.size(16.dp),
painter = painterResource(id = R.drawable.ic_alert_triange_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.status.attention,
)
}
}
}
@Composable
private fun LoadedBalanceColumn(state: BalanceDisplayState.Loaded, modifier: Modifier = Modifier) {
BalanceColumnLayout(modifier) {
Text(
text = state.fiatBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
text = state.cryptoBalance.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
}
}
@Composable
private fun HiddenBalance(modifier: Modifier = Modifier) {
BalanceColumnLayout(modifier) {
Text(
text = StringsSigns.THREE_STARS,
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
text = StringsSigns.THREE_STARS,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.features.feed.ui.search.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.pluralStringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.components.LayeringIcons
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
@Composable
internal fun GroupedUserAssetItem(item: UserAssetItemUM.Grouped, modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
) {
TangemRowContainer(
modifier = Modifier.clickable(onClick = item.onClick),
content = {
LayeringIcons(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x1),
tangemIconUM = item.icon,
count = item.tokensCount,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = item.tokenName,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = pluralStringResourceSafe(R.plurals.common_tokens_count, item.tokensCount, item.tokensCount),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BalanceColumn(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP),
balanceState = item.balanceState,
isBalanceHidden = item.isBalanceHidden,
)
TangemButton(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(TangemRowLayoutId.TAIL),
buttonUM = TangemButtonUM(
type = TangemButtonType.Secondary,
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_chevron_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X10,
onClick = item.onClick,
),
)
},
)
}
}

View file

@ -0,0 +1,119 @@
package com.tangem.features.feed.ui.search.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
@Composable
fun SingleUserAssetItem(shouldUsePriceBlock: Boolean, item: UserAssetItemUM.Single, modifier: Modifier = Modifier) {
TangemRowContainer(
modifier = modifier.clickable(onClick = item.onClick),
content = {
TangemIcon(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.HEAD)
.size(40.dp)
.padding(end = TangemTheme.dimens2.x1),
tangemIconUM = item.icon,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = item.tokenName,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
if (shouldUsePriceBlock) {
PriceBlock(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
priceChangeState = item.priceChangeState,
fiatRate = item.fiatRate,
balanceState = item.balanceState,
)
} else {
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = item.networkName,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
BalanceColumn(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP),
balanceState = item.balanceState,
isBalanceHidden = item.isBalanceHidden,
)
},
)
}
@Composable
private fun PriceBlock(
priceChangeState: PriceChangeState,
fiatRate: String?,
balanceState: BalanceDisplayState,
modifier: Modifier = Modifier,
) {
val isDisabled = remember(balanceState) {
balanceState is BalanceDisplayState.Unreachable
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
if (fiatRate != null) {
Text(
text = fiatRate,
style = TangemTheme.typography2.captionMedium12,
color = if (isDisabled) {
TangemTheme.colors2.text.status.disabled
} else {
TangemTheme.colors2.text.neutral.secondary
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
AnimatedContent(
targetState = priceChangeState,
contentKey = { it::class },
) { animatedState ->
when (animatedState) {
is PriceChangeState.Content -> {
PriceChangeInPercent(
valueInPercent = animatedState.valueInPercent,
type = animatedState.type,
textStyle = TangemTheme.typography2.captionMedium12,
isDisabled = isDisabled,
)
}
PriceChangeState.Unknown -> Unit
}
}
}
}

View file

@ -0,0 +1,146 @@
package com.tangem.features.feed.ui.search.components
import android.content.res.Configuration
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.Fade
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.feed.ui.search.preview.TokenSelectorContentPreviewProvider
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.rememberHazeState
@Composable
internal fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<TokenSelectorContentUM>(
config = config,
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors2.surface.level2,
content = { content ->
TokenSelectorContent(content, config.onDismissRequest)
},
)
}
@Composable
private fun TokenSelectorContent(content: TokenSelectorContentUM, onDismiss: () -> Unit) {
val hazeState = rememberHazeState()
var topBarHeight by remember { mutableStateOf(0.dp) }
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.hazeSourceTangem(state = hazeState, 1f),
contentPadding = PaddingValues(
start = TangemTheme.dimens2.x4,
end = TangemTheme.dimens2.x4,
top = topBarHeight,
bottom = TangemTheme.dimens2.x10,
),
) {
tokenSelectorSectionItems(content.sections)
}
TokenSelectorSheetTopBar(
modifier = Modifier.align(Alignment.TopEnd),
onDismiss = onDismiss,
hazeState = hazeState,
onChangeHeight = { topBarHeight = it },
)
Fade(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
height = TangemTheme.dimens2.x10,
)
}
}
@Composable
private fun TokenSelectorSheetTopBar(
hazeState: HazeState,
onDismiss: () -> Unit,
onChangeHeight: (Dp) -> Unit,
modifier: Modifier = Modifier,
) {
val bgColor = TangemTheme.colors2.surface.level2
val density = LocalDensity.current
TangemTopBar(
modifier = modifier
.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
onChangeHeight(coordinates.size.height.toDp())
}
}
}
.hazeEffectTangem(state = hazeState) {
backgroundColor = bgColor
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
preferPerformance = true,
easing = EaseOut,
)
},
type = TangemTopBarType.BottomSheet,
title = resourceReference(R.string.markets_search_portfolio_header),
endContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_close_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(onClick = onDismiss)
.padding(TangemTheme.dimens2.x2_5),
)
},
)
}
@Preview
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TokenSelectorBottomSheetPreview(
@PreviewParameter(TokenSelectorContentPreviewProvider::class) content: TokenSelectorContentUM,
) {
TangemThemePreviewRedesign {
TokenSelectorBottomSheet(
config = TangemBottomSheetConfig(
onDismissRequest = {},
content = content,
isShown = true,
),
)
}
}

View file

@ -0,0 +1,140 @@
package com.tangem.features.feed.ui.search.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.common.ui.account.getResId
import com.tangem.common.ui.account.getUiColor
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.search.state.AccountHeaderData
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import kotlinx.collections.immutable.ImmutableList
internal fun LazyListScope.tokenSelectorSectionItems(sections: ImmutableList<TokenSelectorSectionUM>) {
sections.forEachIndexed { index, section ->
when (section) {
is TokenSelectorSectionUM.WalletHeader -> {
item(key = "wallet_${section.walletName}_$index") {
WalletHeaderSection(section)
}
}
is TokenSelectorSectionUM.TokenGroup -> {
if (section.items.isEmpty()) return@forEachIndexed
if (index > 0 && sections[index - 1] is TokenSelectorSectionUM.TokenGroup) {
item(key = "spacer_before_group_$index") {
SpacerH(TangemTheme.dimens2.x2)
}
}
val lastIndex = if (section.accountHeader != null) {
section.items.size
} else {
section.items.lastIndex.coerceAtLeast(0)
}
section.accountHeader?.let { header ->
item(key = "account_header_$index") {
TokenGroupAccountHeaderRow(
data = header,
modifier = Modifier.tokenGroupRowDecoration(
currentIndex = 0,
lastIndex = lastIndex,
),
)
}
}
val indexOffset = if (section.accountHeader != null) 1 else 0
section.items.forEachIndexed { itemIndex, single ->
item(key = "token_${single.id}_$index") {
SingleUserAssetItem(
item = single,
modifier = Modifier.tokenGroupRowDecoration(
currentIndex = indexOffset + itemIndex,
lastIndex = lastIndex,
),
shouldUsePriceBlock = false,
)
}
}
}
}
}
}
@Composable
private fun Modifier.tokenGroupRowDecoration(currentIndex: Int, lastIndex: Int): Modifier =
this.roundedShapeItemDecoration(
currentIndex = currentIndex,
lastIndex = lastIndex,
addDefaultPadding = false,
radius = TangemTheme.dimens2.x6,
backgroundColor = TangemTheme.colors2.surface.level3,
)
@Composable
private fun WalletHeaderSection(section: TokenSelectorSectionUM.WalletHeader) {
Row(
modifier = Modifier
.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2)
.padding(horizontal = TangemTheme.dimens2.x3),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Text(
text = section.walletName,
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_key_card_20),
modifier = Modifier.size(TangemTheme.dimens2.x5),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
)
}
}
@Composable
private fun TokenGroupAccountHeaderRow(data: AccountHeaderData, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2)
.padding(horizontal = TangemTheme.dimens2.x4),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x4),
imageVector = ImageVector.vectorResource(data.cryptoPortfolioIcon.value.getResId()),
tint = data.cryptoPortfolioIcon.color.getUiColor(),
contentDescription = null,
)
Text(
text = data.accountName.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View file

@ -2,11 +2,7 @@ package com.tangem.features.feed.ui.search.preview
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@ -15,7 +11,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -206,19 +205,30 @@ internal object SearchContentPreviewFixtures {
updateTimestamp = updateTimestamp,
)
private fun userAsset(
id: String,
name: String,
symbol: String,
accountName: String,
iconUrl: String? = null,
): UserAssetItemUM = UserAssetItemUM(
private fun userAsset(id: String, name: String, symbol: String): UserAssetItemUM = UserAssetItemUM.Single(
id = id,
tokenIconUrl = iconUrl,
icon = TangemIconUM.Currency(
CurrencyIconState.CoinIcon(
url = null,
fallbackResId = com.tangem.core.ui.R.drawable.ic_ethereumpow_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
tokenName = name,
tokenSymbol = symbol,
accountName = accountName,
fiatRate = "$98,765.43",
priceChangeState = PriceChangeState.Content(
type = PriceChangeType.UP,
valueInPercent = "+2.34%",
),
balanceState = BalanceDisplayState.Loaded(
cryptoBalance = stringReference("1.234 $symbol"),
fiatBalance = stringReference("$121,876.50"),
),
isBalanceHidden = false,
onClick = {},
networkName = "Ethereum",
)
private fun textHint(text: String): TextHintItemUM = TextHintItemUM(text = text)
@ -251,13 +261,8 @@ internal object SearchContentPreviewFixtures {
)
private fun portfolioTwo(): ImmutableList<UserAssetItemUM> = persistentListOf(
userAsset(id = "p1", name = "Ethereum", symbol = "ETH", accountName = "Main wallet"),
userAsset(
id = "p2",
name = "Polygon",
symbol = "POL",
accountName = "Account with a long label for preview",
),
userAsset(id = "p1", name = "Ethereum", symbol = "ETH"),
userAsset(id = "p2", name = "Polygon", symbol = "POL"),
)
private fun marketListShort(): ImmutableList<MarketsListItemUM> = persistentListOf(
@ -390,6 +395,7 @@ private val SearchContentPreviewCallbacks = SearchCallbacks(
onClearHintsClick = {},
onTextHintClick = { _ -> },
onResultMarketTokenClick = { _ -> },
onHistoryTokenClick = { _ -> },
)
/** All [SearchContentPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */

View file

@ -0,0 +1,122 @@
package com.tangem.features.feed.ui.search.preview
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.feed.ui.search.state.AccountHeaderData
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import kotlinx.collections.immutable.persistentListOf
@Suppress("StringLiteralDuplication")
internal class TokenSelectorContentPreviewProvider :
CollectionPreviewParameterProvider<TokenSelectorContentUM>(
listOf(
tokenSelectorPreviewSimple(),
tokenSelectorPreviewWithAccountHeaders(),
tokenSelectorPreviewMultiWallet(),
),
)
private fun tokenSelectorPreviewSimple(): TokenSelectorContentUM {
return TokenSelectorContentUM(
sections = persistentListOf(
TokenSelectorSectionUM.TokenGroup(
accountHeader = null,
items = persistentListOf(
previewTokenItem(id = "eth", name = "Ethereum", symbol = "ETH"),
previewTokenItem(id = "btc", name = "Bitcoin", symbol = "BTC"),
),
),
),
)
}
private fun tokenSelectorPreviewWithAccountHeaders(): TokenSelectorContentUM {
val accountIcon = CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Wallet,
color = CryptoPortfolioIcon.Color.CaribbeanBlue,
)
return TokenSelectorContentUM(
sections = persistentListOf(
TokenSelectorSectionUM.TokenGroup(
accountHeader = AccountHeaderData(
accountName = stringReference(value = "Main account"),
cryptoPortfolioIcon = accountIcon,
),
items = persistentListOf(
previewTokenItem(id = "eth_main", name = "Ethereum", symbol = "ETH"),
),
),
TokenSelectorSectionUM.TokenGroup(
accountHeader = AccountHeaderData(
accountName = stringReference(value = "Trading"),
cryptoPortfolioIcon = accountIcon,
),
items = persistentListOf(
previewTokenItem(id = "sol_trade", name = "Solana", symbol = "SOL"),
previewTokenItem(id = "avax_trade", name = "Avalanche", symbol = "AVAX"),
),
),
),
)
}
private fun tokenSelectorPreviewMultiWallet(): TokenSelectorContentUM {
return TokenSelectorContentUM(
sections = persistentListOf(
TokenSelectorSectionUM.WalletHeader(walletName = "Cold wallet"),
TokenSelectorSectionUM.TokenGroup(
accountHeader = null,
items = persistentListOf(
previewTokenItem(id = "btc_cold", name = "Bitcoin", symbol = "BTC"),
),
),
TokenSelectorSectionUM.WalletHeader(walletName = "Hot wallet"),
TokenSelectorSectionUM.TokenGroup(
accountHeader = null,
items = persistentListOf(
previewTokenItem(id = "eth_hot", name = "Ethereum", symbol = "ETH"),
previewTokenItem(id = "usdt_hot", name = "Tether", symbol = "USDT"),
),
),
),
)
}
private fun previewTokenItem(id: String, name: String, symbol: String): UserAssetItemUM.Single {
val cryptoRef = stringReference(value = "1.234 $symbol")
val fiatRef = stringReference(value = "$1,234.56")
return UserAssetItemUM.Single(
id = id,
icon = TangemIconUM.Currency(
CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.ic_ethereumpow_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
tokenName = name,
tokenSymbol = symbol,
fiatRate = "$98,765.43",
priceChangeState = PriceChangeState.Content(
valueInPercent = "+2.34%",
type = PriceChangeType.UP,
),
balanceState = BalanceDisplayState.Loaded(
cryptoBalance = cryptoRef,
fiatBalance = fiatRef,
),
isBalanceHidden = false,
onClick = {},
networkName = "Ethereum",
)
}

View file

@ -0,0 +1,245 @@
package com.tangem.features.feed.ui.search.preview
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem
import com.tangem.features.feed.ui.search.components.SingleUserAssetItem
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
/** Labeled UI state for [SingleUserAssetItem] previews (dropdown label in Studio). */
internal data class SingleUserAssetItemPreviewScenario(
val title: String,
val item: UserAssetItemUM.Single,
)
/** Labeled UI state for [GroupedUserAssetItem] previews. */
internal data class GroupedUserAssetItemPreviewScenario(
val title: String,
val item: UserAssetItemUM.Grouped,
)
@Suppress("StringLiteralDuplication")
internal object UserAssetItemPreviewFixtures {
private val sampleIcon = TangemIconUM.Currency(
CurrencyIconState.CoinIcon(
url = null,
fallbackResId = com.tangem.core.ui.R.drawable.ic_ethereumpow_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
)
private val cryptoRef = stringReference("1.234 ETH")
private val fiatRef = stringReference("$121,876.50")
private val samplePriceChange = PriceChangeState.Content(
valueInPercent = "+2.34%",
type = PriceChangeType.UP,
)
private fun balanceLoaded() = BalanceDisplayState.Loaded(
cryptoBalance = cryptoRef,
fiatBalance = fiatRef,
)
private fun balanceFlickering() = BalanceDisplayState.Flickering(
cryptoBalance = cryptoRef,
fiatBalance = fiatRef,
)
private fun balanceStale() = BalanceDisplayState.Stale(
cryptoBalance = cryptoRef,
fiatBalance = fiatRef,
)
private fun balanceLoading() = BalanceDisplayState.Loading
private fun balanceUnreachable() = BalanceDisplayState.Unreachable
fun allSingleScenarios(): List<SingleUserAssetItemPreviewScenario> = listOf(
SingleUserAssetItemPreviewScenario(
title = "Hidden (balanceState ignored)",
item = single(
balanceState = balanceLoaded(),
isBalanceHidden = true,
),
),
SingleUserAssetItemPreviewScenario(
title = "Balance Loaded",
item = single(balanceState = balanceLoaded(), isBalanceHidden = false),
),
SingleUserAssetItemPreviewScenario(
title = "Balance Flickering",
item = single(balanceState = balanceFlickering(), isBalanceHidden = false),
),
SingleUserAssetItemPreviewScenario(
title = "Balance Stale",
item = single(balanceState = balanceStale(), isBalanceHidden = false),
),
SingleUserAssetItemPreviewScenario(
title = "Balance Loading",
item = single(balanceState = balanceLoading(), isBalanceHidden = false),
),
SingleUserAssetItemPreviewScenario(
title = "Balance Unreachable",
item = single(balanceState = balanceUnreachable(), isBalanceHidden = false),
),
)
fun allGroupedScenarios(): List<GroupedUserAssetItemPreviewScenario> = listOf(
GroupedUserAssetItemPreviewScenario(
title = "Hidden (balanceState ignored)",
item = grouped(
balanceState = balanceLoaded(),
isBalanceHidden = true,
),
),
GroupedUserAssetItemPreviewScenario(
title = "Balance Loaded",
item = grouped(balanceState = balanceLoaded(), isBalanceHidden = false),
),
GroupedUserAssetItemPreviewScenario(
title = "Balance Flickering",
item = grouped(balanceState = balanceFlickering(), isBalanceHidden = false),
),
GroupedUserAssetItemPreviewScenario(
title = "Balance Stale",
item = grouped(balanceState = balanceStale(), isBalanceHidden = false),
),
GroupedUserAssetItemPreviewScenario(
title = "Balance Loading",
item = grouped(balanceState = balanceLoading(), isBalanceHidden = false),
),
GroupedUserAssetItemPreviewScenario(
title = "Balance Unreachable",
item = grouped(balanceState = balanceUnreachable(), isBalanceHidden = false),
),
)
private fun single(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Single =
UserAssetItemUM.Single(
id = "single_preview",
icon = sampleIcon,
tokenName = "Ethereum",
tokenSymbol = "ETH",
fiatRate = "$98,765.43",
priceChangeState = samplePriceChange,
balanceState = balanceState,
isBalanceHidden = isBalanceHidden,
onClick = {},
networkName = "Ethereum",
)
private fun grouped(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Grouped =
UserAssetItemUM.Grouped(
id = "grouped_preview",
icon = sampleIcon,
tokenName = "Ethereum",
tokenSymbol = "ETH",
tokensCount = 3,
balanceState = balanceState,
isBalanceHidden = isBalanceHidden,
onClick = {},
)
}
internal class SingleUserAssetItemPreviewParameterProvider :
PreviewParameterProvider<SingleUserAssetItemPreviewScenario> {
override val values: Sequence<SingleUserAssetItemPreviewScenario>
get() = UserAssetItemPreviewFixtures.allSingleScenarios().asSequence()
}
internal class GroupedUserAssetItemPreviewParameterProvider :
PreviewParameterProvider<GroupedUserAssetItemPreviewScenario> {
override val values: Sequence<GroupedUserAssetItemPreviewScenario>
get() = UserAssetItemPreviewFixtures.allGroupedScenarios().asSequence()
}
@Composable
private fun SingleUserAssetItemPreviewHost(
scenario: SingleUserAssetItemPreviewScenario,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors2.surface.level1)
.padding(8.dp),
) {
Box(
modifier = Modifier.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
) {
SingleUserAssetItem(item = scenario.item, shouldUsePriceBlock = true)
}
}
}
@Composable
private fun GroupedUserAssetItemPreviewHost(
scenario: GroupedUserAssetItemPreviewScenario,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors2.surface.level1)
.padding(8.dp),
) {
GroupedUserAssetItem(item = scenario.item)
}
}
@Composable
@Preview(name = "Single all balance states (parameter)", showBackground = true, widthDp = 360)
@Preview(
name = "Single all balance states (night)",
showBackground = true,
widthDp = 360,
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
private fun SingleUserAssetItemPreview_AllBalanceStates(
@PreviewParameter(SingleUserAssetItemPreviewParameterProvider::class) scenario: SingleUserAssetItemPreviewScenario,
) {
TangemThemePreviewRedesign {
SingleUserAssetItemPreviewHost(scenario = scenario)
}
}
@Composable
@Preview(name = "Grouped all balance states (parameter)", showBackground = true, widthDp = 360)
@Preview(
name = "Grouped all balance states (night)",
showBackground = true,
widthDp = 360,
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
private fun GroupedUserAssetItemPreview_AllBalanceStates(
@PreviewParameter(GroupedUserAssetItemPreviewParameterProvider::class)
scenario: GroupedUserAssetItemPreviewScenario,
) {
TangemThemePreviewRedesign {
GroupedUserAssetItemPreviewHost(scenario = scenario)
}
}

View file

@ -7,4 +7,5 @@ internal data class SearchCallbacks(
val onClearHintsClick: () -> Unit,
val onTextHintClick: (hint: String) -> Unit,
val onResultMarketTokenClick: (MarketsListItemUM) -> Unit,
val onHistoryTokenClick: (MarketsListItemUM) -> Unit,
)

View file

@ -3,6 +3,9 @@ package com.tangem.features.feed.ui.search.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
data class SearchUM(
@ -42,11 +45,57 @@ sealed interface MarketSearchResultUM {
data class TextHintItemUM(val text: String)
data class UserAssetItemUM(
val id: String,
val tokenIconUrl: String?,
val tokenName: String,
val tokenSymbol: String,
val accountName: String,
val onClick: () -> Unit,
)
@Immutable
sealed interface BalanceDisplayState {
data class Loaded(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data class Flickering(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data class Stale(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data object Loading : BalanceDisplayState
data object Unreachable : BalanceDisplayState
}
@Immutable
sealed interface UserAssetItemUM {
val id: String
val icon: TangemIconUM
val tokenName: String
val tokenSymbol: String
val onClick: () -> Unit
data class Single(
override val id: String,
override val icon: TangemIconUM,
override val tokenName: String,
override val tokenSymbol: String,
val fiatRate: String?,
val priceChangeState: PriceChangeState,
val balanceState: BalanceDisplayState,
val isBalanceHidden: Boolean,
val networkName: String,
override val onClick: () -> Unit,
) : UserAssetItemUM
data class Grouped(
override val id: String,
override val icon: TangemIconUM,
override val tokenName: String,
override val tokenSymbol: String,
val tokensCount: Int,
val balanceState: BalanceDisplayState,
val isBalanceHidden: Boolean,
override val onClick: () -> Unit,
) : UserAssetItemUM
}

View file

@ -0,0 +1,29 @@
package com.tangem.features.feed.ui.search.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class TokenSelectorContentUM(
val sections: ImmutableList<TokenSelectorSectionUM>,
) : TangemBottomSheetConfigContent
@Immutable
internal data class AccountHeaderData(
val accountName: TextReference,
val cryptoPortfolioIcon: CryptoPortfolioIcon,
)
@Immutable
internal sealed interface TokenSelectorSectionUM {
data class WalletHeader(val walletName: String) : TokenSelectorSectionUM
data class TokenGroup(
val accountHeader: AccountHeaderData?,
val items: ImmutableList<UserAssetItemUM.Single>,
) : TokenSelectorSectionUM
}

View file

@ -27,11 +27,7 @@ internal fun contentFeedEntryStackAnimation(): StackAnimation<
ComposableModularBottomSheetContentComponent,
> =
stackAnimation { to, from, _ ->
val isSearchToTokenList =
(to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true
val isFromSearchTokenList =
(from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true
if (isSearchToTokenList || isFromSearchTokenList) {
if (to.configuration.usesFadeStackTransition() || from.configuration.usesFadeStackTransition()) {
fade()
} else {
slide()

View file

@ -0,0 +1,304 @@
package com.tangem.features.feed.model.search.state.transformers
import com.google.common.truth.Truth
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import io.mockk.*
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BuildTokenSelectorSectionsTransformerTest {
private val appCurrency: AppCurrency = AppCurrency.Default
private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true)
private val prevState = TokenSelectorContentUM(sections = persistentListOf())
@BeforeEach
fun setup() {
clearMocks(onTokenSelected)
}
@Test
fun `should return empty sections when entries list is empty`() {
val transformer = createTransformer(entries = emptyList())
val result = transformer.transform(prevState)
Truth.assertThat(result.sections).isEmpty()
}
@Test
fun `should create single TokenGroup without wallet header for single wallet`() {
val walletId = createMockUserWalletId("wallet1")
val accountId = createMockAccountId("account1")
val entries = listOf(
createMockEntry(walletId, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"),
createMockEntry(walletId, "Wallet 1", accountId, "eth", "Ethereum", "ETH"),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevState)
Truth.assertThat(result.sections).hasSize(1)
Truth.assertThat(result.sections[0]).isInstanceOf(TokenSelectorSectionUM.TokenGroup::class.java)
val group = result.sections[0] as TokenSelectorSectionUM.TokenGroup
Truth.assertThat(group.items).hasSize(2)
Truth.assertThat(group.accountHeader).isNull()
}
@Test
fun `should add wallet headers when multiple wallets present`() {
val walletId1 = createMockUserWalletId("wallet1")
val walletId2 = createMockUserWalletId("wallet2")
val accountId = createMockAccountId("account1")
val entries = listOf(
createMockEntry(walletId1, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"),
createMockEntry(walletId2, "Wallet 2", accountId, "eth", "Ethereum", "ETH"),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevState)
val walletHeaders = result.sections.filterIsInstance<TokenSelectorSectionUM.WalletHeader>()
val tokenGroups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
Truth.assertThat(walletHeaders).hasSize(2)
Truth.assertThat(walletHeaders[0].walletName).isEqualTo("Wallet 1")
Truth.assertThat(walletHeaders[1].walletName).isEqualTo("Wallet 2")
Truth.assertThat(tokenGroups).hasSize(2)
}
@Test
fun `should not show account headers when single account per wallet`() {
val walletId = createMockUserWalletId("wallet1")
val accountId = createMockAccountId("account1")
val entries = listOf(
createMockEntry(walletId, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"),
createMockEntry(walletId, "Wallet 1", accountId, "eth", "Ethereum", "ETH"),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevState)
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
Truth.assertThat(groups).hasSize(1)
Truth.assertThat(groups[0].accountHeader).isNull()
}
@Test
fun `should show account headers when multiple accounts in same wallet`() {
val walletId = createMockUserWalletId("wallet1")
val accountId1 = createMockAccountId("account1")
val accountId2 = createMockAccountId("account2")
val entries = listOf(
createMockEntry(walletId, "Wallet 1", accountId1, "btc", "Bitcoin", "BTC"),
createMockEntry(walletId, "Wallet 1", accountId2, "eth", "Ethereum", "ETH"),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevState)
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
Truth.assertThat(groups).hasSize(2)
Truth.assertThat(groups[0].accountHeader).isNotNull()
Truth.assertThat(groups[1].accountHeader).isNotNull()
}
@Test
fun `should group entries by wallet and then by account`() {
val walletId1 = createMockUserWalletId("wallet1")
val walletId2 = createMockUserWalletId("wallet2")
val account1 = createMockAccountId("acc1")
val account2 = createMockAccountId("acc2")
val entries = listOf(
createMockEntry(walletId1, "W1", account1, "btc", "Bitcoin", "BTC"),
createMockEntry(walletId1, "W1", account2, "eth", "Ethereum", "ETH"),
createMockEntry(walletId2, "W2", account1, "sol", "Solana", "SOL"),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevState)
// 2 wallet headers + 2 token groups for wallet1 (2 accounts) + 1 token group for wallet2
val walletHeaders = result.sections.filterIsInstance<TokenSelectorSectionUM.WalletHeader>()
val tokenGroups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
Truth.assertThat(walletHeaders).hasSize(2)
Truth.assertThat(tokenGroups).hasSize(3)
// Wallet1 has 2 accounts so headers should be present
Truth.assertThat(tokenGroups[0].accountHeader).isNotNull()
Truth.assertThat(tokenGroups[1].accountHeader).isNotNull()
// Wallet2 has 1 account so no account header
Truth.assertThat(tokenGroups[2].accountHeader).isNull()
}
@Test
fun `should correctly place multiple tokens in same account group`() {
val walletId = createMockUserWalletId("wallet1")
val accountId = createMockAccountId("account1")
val entries = listOf(
createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"),
createMockEntry(walletId, "W1", accountId, "eth", "Ethereum", "ETH"),
createMockEntry(walletId, "W1", accountId, "sol", "Solana", "SOL"),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevState)
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
Truth.assertThat(groups).hasSize(1)
Truth.assertThat(groups[0].items).hasSize(3)
}
@Test
fun `should preserve order of wallets and accounts`() {
val walletId1 = createMockUserWalletId("wallet1")
val walletId2 = createMockUserWalletId("wallet2")
val account1 = createMockAccountId("acc1")
val account2 = createMockAccountId("acc2")
val entries = listOf(
createMockEntry(walletId1, "First Wallet", account1, "btc", "Bitcoin", "BTC"),
createMockEntry(walletId1, "First Wallet", account2, "eth", "Ethereum", "ETH"),
createMockEntry(walletId2, "Second Wallet", account1, "sol", "Solana", "SOL"),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevState)
val headers = result.sections.filterIsInstance<TokenSelectorSectionUM.WalletHeader>()
Truth.assertThat(headers[0].walletName).isEqualTo("First Wallet")
Truth.assertThat(headers[1].walletName).isEqualTo("Second Wallet")
}
@Test
fun `should convert entries to UserAssetItemUM Single`() {
val walletId = createMockUserWalletId("wallet1")
val accountId = createMockAccountId("account1")
val entries = listOf(
createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevState)
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
Truth.assertThat(groups).hasSize(1)
val item = groups[0].items[0]
Truth.assertThat(item.tokenName).isEqualTo("Bitcoin")
Truth.assertThat(item.tokenSymbol).isEqualTo("BTC")
}
@Test
fun `should ignore previous state and build from scratch`() {
val walletId = createMockUserWalletId("wallet1")
val accountId = createMockAccountId("account1")
val entries = listOf(
createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"),
)
val prevStateWithSections = TokenSelectorContentUM(
sections = persistentListOf(
TokenSelectorSectionUM.WalletHeader(walletName = "Old Wallet"),
),
)
val transformer = createTransformer(entries)
val result = transformer.transform(prevStateWithSections)
val headers = result.sections.filterIsInstance<TokenSelectorSectionUM.WalletHeader>()
Truth.assertThat(headers).isEmpty()
val groups = result.sections.filterIsInstance<TokenSelectorSectionUM.TokenGroup>()
Truth.assertThat(groups).hasSize(1)
}
// region Helpers
private fun createTransformer(entries: List<UserAssetSearchEntry>): BuildTokenSelectorSectionsTransformer {
return BuildTokenSelectorSectionsTransformer(
entries = entries,
appCurrency = appCurrency,
isBalanceHidden = false,
onTokenSelected = onTokenSelected,
)
}
private fun createMockUserWalletId(id: String): UserWalletId {
return mockk<UserWalletId> {
every { stringValue } returns id
}
}
private fun createMockAccountId(id: String): AccountId {
return mockk<AccountId> {
every { value } returns id
}
}
private fun createMockEntry(
walletId: UserWalletId = createMockUserWalletId("wallet1"),
walletName: String = "Wallet 1",
accountId: AccountId = createMockAccountId("account1"),
currencyId: String = "btc",
currencyName: String = "Bitcoin",
currencySymbol: String = "BTC",
): UserAssetSearchEntry {
val network = mockk<Network>(relaxed = true) {
every { name } returns "Network"
}
val currencyIdObj = mockk<CryptoCurrency.ID> {
every { value } returns currencyId
}
val currency = mockk<CryptoCurrency.Coin> {
every { id } returns currencyIdObj
every { name } returns currencyName
every { symbol } returns currencySymbol
every { this@mockk.network } returns network
every { decimals } returns 8
every { iconUrl } returns null
every { isCustom } returns false
}
val currencyStatus = mockk<CryptoCurrencyStatus> {
every { this@mockk.currency } returns currency
every { value } returns CryptoCurrencyStatus.Loaded(
amount = BigDecimal("1.0"),
fiatAmount = BigDecimal("30000.0"),
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("1.0"),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
}
return mockk<UserAssetSearchEntry> {
every { userWalletId } returns walletId
every { userWalletName } returns walletName
every { this@mockk.accountId } returns accountId
every { accountName } returns AccountName.DefaultMain
every { accountIcon } returns mockk(relaxed = true)
every { this@mockk.currencyStatus } returns currencyStatus
}
}
// endregion
}

View file

@ -0,0 +1,358 @@
package com.tangem.features.feed.model.search.state.transformers
import com.google.common.truth.Truth
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TokenSelectorEntryConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true)
private lateinit var converter: TokenSelectorEntryConverter
@BeforeEach
fun setup() {
clearMocks(onTokenSelected)
converter = TokenSelectorEntryConverter(
appCurrency = appCurrency,
isBalanceHidden = false,
onTokenSelected = onTokenSelected,
)
}
@Test
fun `should convert entry with Loaded status to Single with Loaded balance`() {
val entry = createMockEntry(
walletId = "wallet1",
accountId = "account1",
currencyId = "btc",
currencyName = "Bitcoin",
currencySymbol = "BTC",
networkName = "Bitcoin",
decimals = 8,
value = createLoadedValue(
amount = BigDecimal("1.5"),
fiatAmount = BigDecimal("45000.0"),
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("2.5"),
),
)
val result = converter.convert(entry)
Truth.assertThat(result.id).isEqualTo("wallet1_account1_btc")
Truth.assertThat(result.tokenName).isEqualTo("Bitcoin")
Truth.assertThat(result.tokenSymbol).isEqualTo("BTC")
Truth.assertThat(result.networkName).isEqualTo("Bitcoin")
Truth.assertThat(result.isBalanceHidden).isFalse()
Truth.assertThat(result.balanceState).isInstanceOf(BalanceDisplayState.Loaded::class.java)
}
@Test
fun `should set balance hidden when isBalanceHidden is true`() {
converter = TokenSelectorEntryConverter(
appCurrency = appCurrency,
isBalanceHidden = true,
onTokenSelected = onTokenSelected,
)
val entry = createMockEntry(
value = createLoadedValue(
amount = BigDecimal("1.0"),
fiatAmount = BigDecimal("30000.0"),
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("1.0"),
),
)
val result = converter.convert(entry)
Truth.assertThat(result.isBalanceHidden).isTrue()
}
@Test
fun `should return Loading balance state for Loading value without amount`() {
val entry = createMockEntry(
value = CryptoCurrencyStatus.Loading,
)
val result = converter.convert(entry)
Truth.assertThat(result.balanceState).isEqualTo(BalanceDisplayState.Loading)
}
@Test
fun `should return Unreachable balance state for Unreachable value without amount`() {
val entry = createMockEntry(
value = createUnreachableValue(),
)
val result = converter.convert(entry)
Truth.assertThat(result.balanceState).isEqualTo(BalanceDisplayState.Unreachable)
}
@Test
fun `should return Unknown price change state for Loading value`() {
val entry = createMockEntry(
value = CryptoCurrencyStatus.Loading,
)
val result = converter.convert(entry)
Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown)
}
@Test
fun `should return Unknown price change state for Unreachable value`() {
val entry = createMockEntry(
value = createUnreachableValue(),
)
val result = converter.convert(entry)
Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown)
}
@Test
fun `should return Unknown price change state for MissedDerivation value`() {
val entry = createMockEntry(
value = createMissedDerivationValue(),
)
val result = converter.convert(entry)
Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown)
}
@Test
fun `should return Unknown price change state for NoAmount value`() {
val entry = createMockEntry(
value = createNoAmountValue(),
)
val result = converter.convert(entry)
Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown)
}
@Test
fun `should return Content price change state with UP type for positive change`() {
val entry = createMockEntry(
value = createLoadedValue(
amount = BigDecimal("1.0"),
fiatAmount = BigDecimal("30000.0"),
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("5.0"),
),
)
val result = converter.convert(entry)
Truth.assertThat(result.priceChangeState).isInstanceOf(PriceChangeState.Content::class.java)
val content = result.priceChangeState as PriceChangeState.Content
Truth.assertThat(content.type).isEqualTo(PriceChangeType.UP)
}
@Test
fun `should return Content price change state with DOWN type for negative change`() {
val entry = createMockEntry(
value = createLoadedValue(
amount = BigDecimal("1.0"),
fiatAmount = BigDecimal("30000.0"),
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("-3.0"),
),
)
val result = converter.convert(entry)
Truth.assertThat(result.priceChangeState).isInstanceOf(PriceChangeState.Content::class.java)
val content = result.priceChangeState as PriceChangeState.Content
Truth.assertThat(content.type).isEqualTo(PriceChangeType.DOWN)
}
@Test
fun `should invoke onTokenSelected callback when onClick is called`() {
val entry = createMockEntry(
value = createLoadedValue(
amount = BigDecimal("1.0"),
fiatAmount = BigDecimal("30000.0"),
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("1.0"),
),
)
val result = converter.convert(entry)
result.onClick()
verify(exactly = 1) { onTokenSelected(entry) }
}
@Test
fun `should generate correct composite id from wallet account and currency`() {
val entry = createMockEntry(
walletId = "myWallet",
accountId = "myAccount",
currencyId = "eth",
)
val result = converter.convert(entry)
Truth.assertThat(result.id).isEqualTo("myWallet_myAccount_eth")
}
@Test
fun `should convert list of entries`() {
val entries = listOf(
createMockEntry(currencyId = "btc", currencyName = "Bitcoin", currencySymbol = "BTC"),
createMockEntry(currencyId = "eth", currencyName = "Ethereum", currencySymbol = "ETH"),
)
val results = converter.convertList(entries)
Truth.assertThat(results).hasSize(2)
Truth.assertThat(results[0].tokenName).isEqualTo("Bitcoin")
Truth.assertThat(results[1].tokenName).isEqualTo("Ethereum")
}
@Test
fun `should return fiatRate formatted string for Loaded value`() {
val entry = createMockEntry(
value = createLoadedValue(
amount = BigDecimal("1.0"),
fiatAmount = BigDecimal("30000.0"),
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("1.0"),
),
)
val result = converter.convert(entry)
Truth.assertThat(result.fiatRate).isNotNull()
}
@Test
fun `should return null fiatRate when value has no fiatRate`() {
val entry = createMockEntry(
value = CryptoCurrencyStatus.Loading,
)
val result = converter.convert(entry)
Truth.assertThat(result.fiatRate).isNull()
}
// region Helpers
private fun createMockEntry(
walletId: String = "wallet1",
accountId: String = "account1",
currencyId: String = "btc",
currencyName: String = "Bitcoin",
currencySymbol: String = "BTC",
networkName: String = "Bitcoin",
decimals: Int = 8,
value: CryptoCurrencyStatus.Value = createLoadedValue(
amount = BigDecimal("1.0"),
fiatAmount = BigDecimal("30000.0"),
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("1.0"),
),
): UserAssetSearchEntry {
val userWalletId = mockk<UserWalletId> {
every { stringValue } returns walletId
}
val accountIdMock = mockk<com.tangem.domain.models.account.AccountId> {
every { this@mockk.value } returns accountId
}
val network = mockk<Network>(relaxed = true) {
every { name } returns networkName
}
val currencyIdObj = mockk<CryptoCurrency.ID> {
every { this@mockk.value } returns currencyId
}
val currency = mockk<CryptoCurrency.Coin> {
every { id } returns currencyIdObj
every { name } returns currencyName
every { symbol } returns currencySymbol
every { this@mockk.network } returns network
every { this@mockk.decimals } returns decimals
every { iconUrl } returns null
every { isCustom } returns false
}
val currencyStatus = mockk<CryptoCurrencyStatus> {
every { this@mockk.currency } returns currency
every { this@mockk.value } returns value
}
return mockk<UserAssetSearchEntry> {
every { this@mockk.userWalletId } returns userWalletId
every { this@mockk.userWalletName } returns "Wallet"
every { this@mockk.accountId } returns accountIdMock
every { this@mockk.accountName } returns mockk(relaxed = true)
every { this@mockk.accountIcon } returns mockk(relaxed = true)
every { this@mockk.currencyStatus } returns currencyStatus
}
}
private fun createLoadedValue(
amount: BigDecimal,
fiatAmount: BigDecimal,
fiatRate: BigDecimal,
priceChange: BigDecimal,
): CryptoCurrencyStatus.Loaded {
return CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = fiatAmount,
fiatRate = fiatRate,
priceChange = priceChange,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
}
private fun createUnreachableValue(): CryptoCurrencyStatus.Unreachable {
return CryptoCurrencyStatus.Unreachable(
priceChange = null,
fiatRate = null,
networkAddress = null,
)
}
private fun createMissedDerivationValue(): CryptoCurrencyStatus.MissedDerivation {
return CryptoCurrencyStatus.MissedDerivation(
priceChange = null,
fiatRate = null,
)
}
private fun createNoAmountValue(): CryptoCurrencyStatus.NoAmount {
return CryptoCurrencyStatus.NoAmount(
priceChange = null,
fiatRate = null,
)
}
// endregion
}