Updated on 2026-08-14
This commit is contained in:
commit
c50840894f
739 changed files with 27664 additions and 4701 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 don’t need to remap at runtime
|
||||
val data = featureDataFlow.value
|
||||
|
||||
// you must control it via [AddToPortfolioManager.state]
|
||||
if (!data.isAvailableToAdd) {
|
||||
finishFlow()
|
||||
return@channelFlow
|
||||
}
|
||||
|
||||
// init data flows, emits on user/code selection, updates state holder
|
||||
val firstSelectedPortfolio = setupPortfolioFlow(data)
|
||||
.onEach { selectedPortfolio.emit(it) }
|
||||
val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio)
|
||||
.onEach { selectedNetwork.emit(it) }
|
||||
|
||||
val isSinglePortfolio = data.isSinglePortfolio
|
||||
if (isSinglePortfolio) {
|
||||
val accountId = data.availableToAddWallets.values.first()
|
||||
.availableToAddAccounts.values.first()
|
||||
.account.account.accountId
|
||||
// force select a portfolio, triggers [selectedPortfolio]
|
||||
portfolioSelectorController.selectAccount(accountId)
|
||||
} else {
|
||||
logAccountSelector(isAccountMode)
|
||||
navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector)
|
||||
}
|
||||
|
||||
val firstPartOfNavigation: Job = firstSelectedPortfolio
|
||||
.onEach { portfolio ->
|
||||
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
|
||||
when {
|
||||
// force select a network, triggers [selectedNetwork]
|
||||
isSingleAvailableNetwork -> {
|
||||
val singleNetwork = portfolio.account.availableToAddNetworks.first()
|
||||
callbackDelegate.onNetworkSelected(singleNetwork)
|
||||
}
|
||||
// it's important to control root screen, UI depends on it(close/arrow icon)
|
||||
isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio))
|
||||
else -> navigation.pushNew(routeToNetworkSelector(portfolio))
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
// main flow that combine all require data
|
||||
val allRequireForAdd = combine(
|
||||
flow = firstSelectedNetwork,
|
||||
flow2 = firstSelectedPortfolio,
|
||||
transform = { a, b -> a to b },
|
||||
)
|
||||
|
||||
// suspend until all required data is selected
|
||||
allRequireForAdd.first()
|
||||
// line of navigation to AddToken screen is finished; cancel the job, select a new root screen
|
||||
firstPartOfNavigation.cancel()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,5 +30,5 @@ internal interface FeedModelClickIntents {
|
|||
|
||||
fun onOpenEarnPage()
|
||||
|
||||
fun openSearch()
|
||||
fun openSearch(source: String)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() &&
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,4 +7,5 @@ internal data class SearchCallbacks(
|
|||
val onClearHintsClick: () -> Unit,
|
||||
val onTextHintClick: (hint: String) -> Unit,
|
||||
val onResultMarketTokenClick: (MarketsListItemUM) -> Unit,
|
||||
val onHistoryTokenClick: (MarketsListItemUM) -> Unit,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue