Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-14 13:57:57 +03:00
commit f3dfb262c2
527 changed files with 12898 additions and 3301 deletions

View file

@ -11,7 +11,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
@ -148,6 +148,13 @@ internal class FeedEntryChildFactory @Inject constructor(
appComponentContext = appComponentContext,
params = DefaultSearchComponent.Params(
onBackClick = onBackClicked,
onMarketTokenClick = { token, currency ->
feedEntryClickIntents.onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
),
)
}

View file

@ -34,7 +34,7 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.earn.EarnModel
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.earn.EarnContent

View file

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

View file

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

View file

@ -1,118 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes
import com.tangem.features.feed.impl.R
@Composable
internal fun AddToPortfolioBottomSheet(
childStack: State<ChildStack<AddToPortfolioRoutes, ComposableContentComponent>>,
onBack: () -> Unit,
onDismiss: () -> Unit,
) {
val stack by childStack
val contentStack = remember { mutableStateOf(stack) }
val currentRoute = stack.active.configuration
val isNotEmpty = currentRoute != AddToPortfolioRoutes.Empty
if (isNotEmpty) {
contentStack.value = stack
}
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
scrollableContent = false,
onBack = onBack,
config = TangemBottomSheetConfig(
isShown = isNotEmpty,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.tertiary,
title = {
AnimatedContent(targetState = contentStack.value, label = "Title Animation") { animatedStack ->
AddToPortfolioBottomSheetTitle(
stack = animatedStack,
onBackClick = onBack,
modifier = Modifier.fillMaxWidth(),
)
}
},
content = {
AnimatedContent(targetState = contentStack.value, label = "Content Animation") { animatedStack ->
val paddingModifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
)
val isScrollableContent = when (animatedStack.active.configuration) {
AddToPortfolioRoutes.PortfolioSelector -> false
AddToPortfolioRoutes.AddToken,
AddToPortfolioRoutes.Empty,
is AddToPortfolioRoutes.NetworkSelector,
AddToPortfolioRoutes.TokenActions,
-> true
}
if (isScrollableContent) {
Column(
modifier = paddingModifier.verticalScroll(rememberScrollState()),
) {
animatedStack.active.instance.Content(modifier = Modifier)
}
} else {
animatedStack.active.instance.Content(modifier = paddingModifier)
}
}
},
)
}
@Composable
private fun AddToPortfolioBottomSheetTitle(
stack: ChildStack<AddToPortfolioRoutes, ComposableContentComponent>,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title: TextReference = when (stack.active.configuration) {
AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token)
AddToPortfolioRoutes.Empty -> TextReference.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network)
AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token)
AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent)
.title.collectAsStateWithLifecycle().value
}
val startIconRes: Int?
val endIconRes: Int?
if (stack.backStack.isNotEmpty()) {
startIconRes = R.drawable.ic_back_24
endIconRes = null
} else {
startIconRes = null
endIconRes = R.drawable.ic_close_24
}
TangemModalBottomSheetTitle(
modifier = modifier,
title = title,
startIconRes = startIconRes,
endIconRes = endIconRes,
onStartClick = onBackClick,
onEndClick = onBackClick,
)
}

View file

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

View file

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

View file

@ -1,110 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl
import androidx.compose.runtime.Composable
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.backStack
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.account.PortfolioSelectorComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel
import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddToPortfolioComponent.Params,
portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
addTokenComponentFactory: AddTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory,
) : AppComponentContext by context, AddToPortfolioComponent {
private val model: AddToPortfolioModel = getOrCreateModel(params)
private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create(
context = child("portfolioSelectorComponent"),
params = PortfolioSelectorComponent.Params(
portfolioFetcher = model.portfolioFetcher,
controller = model.portfolioSelectorController,
),
)
private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create(
context = child("addTokenComponent"),
params = AddTokenComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
selectedPortfolio = model.selectedPortfolio,
selectedNetwork = model.selectedNetwork,
),
)
private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create(
context = child("tokenActionsComponent"),
params = TokenActionsComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
data = model.tokenActionsData,
),
)
private val childStack = childStack(
key = "addToPortfolioStack",
handleBackButton = true,
source = model.navigation,
serializer = AddToPortfolioRoutes.serializer(),
initialStack = { model.currentStack },
childFactory = ::contentChild,
)
private fun onBack() {
if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss()
}
override fun dismiss() {
params.callback.onDismiss()
}
@Composable
override fun BottomSheet() {
AddToPortfolioBottomSheet(
childStack = childStack.subscribeAsState(),
onBack = ::onBack,
onDismiss = ::dismiss,
)
}
private fun contentChild(
config: AddToPortfolioRoutes,
componentContext: ComponentContext,
): ComposableContentComponent = when (config) {
AddToPortfolioRoutes.AddToken -> addTokenComponent
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create(
context = childByContext(componentContext),
params = ChooseNetworkComponent.Params(
selectedPortfolio = config.selectedPortfolio,
callbacks = model,
),
)
}
@AssistedFactory
interface Factory : AddToPortfolioComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddToPortfolioComponent.Params,
): DefaultAddToPortfolioComponent
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,60 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.add.impl.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent
import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM
import com.tangem.features.feed.components.market.details.portfolio.impl.model.BlockchainRowUMConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class ChooseNetworkModel @Inject constructor(
paramsContainer: ParamsContainer,
private val checkCurrencyUnsupportedDelegate: CheckCurrencyUnsupportedDelegate,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<ChooseNetworkComponent.Params>()
val uiState: StateFlow<ChooseNetworkUM> = MutableStateFlow(buildUI())
private fun buildUI(): ChooseNetworkUM {
val allAvailable = params.selectedPortfolio.account.availableNetworks
val alreadyAdded = allAvailable
.subtract(params.selectedPortfolio.account.availableToAddNetworks)
val converter = BlockchainRowUMConverter(
alreadyAddedNetworks = alreadyAdded.mapTo(mutableSetOf()) { it.networkId },
)
val allAvailableNetworks = allAvailable.map { it to true }
return ChooseNetworkUM(
networks = converter.convertList(allAvailableNetworks).toPersistentList(),
onNetworkClick = onNetworkClick@{ row ->
val network = allAvailable
.find { it.networkId == row.id }
?: return@onNetworkClick
checkNetwork(row, network)
},
)
}
private fun checkNetwork(row: BlockchainRowUM, network: TokenMarketInfo.Network) = modelScope.launch {
val selectedWalletId = params.selectedPortfolio.userWallet.walletId
val unsupportedState = checkCurrencyUnsupportedDelegate.checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = row.id,
isMainNetwork = row.isMainNetwork,
)
if (unsupportedState == null) {
params.callbacks.onNetworkSelected(network)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel
import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,8 @@ import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -14,12 +16,11 @@ 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.AddToPortfolioComponent
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.*

View file

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

View file

@ -1,178 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.feed.impl.R
import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
) {
private val disabledActionsInDemoMode = buildSet {
add(TokenActionsBSContentUM.Action.Sell)
}
fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
onHandleQuickAction(
HandledQuickAction(
action = action,
cryptoCurrencyData = cryptoCurrencyData,
),
)
val userWallet = cryptoCurrencyData.userWallet
if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return
when (action) {
TokenActionsBSContentUM.Action.Buy -> onBuyClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Exchange -> onExchangeClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Receive -> Unit
TokenActionsBSContentUM.Action.CopyAddress -> onCopyAddress(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Sell -> onSellClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Send -> onSendClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.Stake -> onStakeClick(cryptoCurrencyData)
TokenActionsBSContentUM.Action.YieldMode -> onYieldModeClick(cryptoCurrencyData)
}
}
private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean {
val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId)
val isNeededShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action)
if (isNeededShowDemoWarning) {
showDemoModeWarning()
}
return isNeededShowDemoWarning
}
private fun showDemoModeWarning() {
val message = DialogMessage(
message = resourceReference(R.string.alert_demo_feature_disabled),
)
messageSender.send(message)
}
private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val cryptoCurrencyStatus = cryptoCurrencyData.status
val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return
val addresses = networkAddress.availableAddresses
.mapToAddressModels(cryptoCurrencyStatus.currency)
.toImmutableList()
val defaultAddress = addresses.firstOrNull()?.value ?: return
clipboardManager.setText(text = defaultAddress, isSensitive = true)
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied)))
}
private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
AppRoute.Onramp(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
source = OnrampSource.MARKETS,
),
)
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
router.push(
AppRoute.Swap(
currencyFrom = cryptoCurrencyData.status.currency,
userWalletId = cryptoCurrencyData.userWallet.walletId,
isInitialReverseOrder = true,
screenSource = AnalyticsParam.ScreensSources.Markets.value,
),
)
}
private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val route = AppRoute.SendEntryPoint(
userWalletId = cryptoCurrencyData.userWallet.walletId,
currency = cryptoCurrencyData.status.currency,
)
router.push(route)
}
private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake }
?.let { it as TokenActionsState.ActionState.Stake }
?.option ?: return
router.push(
AppRoute.Staking(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrency = cryptoCurrencyData.status.currency,
integrationId = option.integrationId,
),
)
}
private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance<TokenActionsState.ActionState.YieldMode>()
.firstOrNull()?.apy ?: return
router.push(
AppRoute.YieldSupplyEntry(
userWalletId = cryptoCurrencyData.userWallet.walletId,
cryptoCurrency = cryptoCurrencyData.status.currency,
apy = yieldSupplyApy,
),
)
}
@AssistedFactory
interface Factory {
fun create(
currentAppCurrency: Provider<AppCurrency>,
onHandleQuickAction: (HandledQuickAction) -> Unit,
): TokenActionsHandler
}
data class HandledQuickAction(
val action: TokenActionsBSContentUM.Action,
val cryptoCurrencyData: PortfolioData.CryptoCurrencyData,
)
}

View file

@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.action.QuickActionUM
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.extensions.resolveReference
@ -36,7 +37,6 @@ import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

View file

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

View file

@ -2,10 +2,10 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.ui.sta
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed interface PortfolioListItem {
@ -30,10 +30,4 @@ internal data class PortfolioTokenUM(
val quickActions: QuickActions,
) : PortfolioListItem {
override val id: String = tokenItemState.id
data class QuickActions(
val actions: ImmutableList<QuickActionUM>,
val onQuickActionClick: (QuickActionUM) -> Unit,
val onQuickActionLongClick: (QuickActionUM) -> Unit,
)
}

View file

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

View file

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

View file

@ -15,6 +15,8 @@ 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
@ -74,6 +76,7 @@ internal class DefaultSearchComponent(
onClearHintsClick = model::clearSearchHistory,
onTextHintClick = model::onTextHintClick,
onResultMarketTokenClick = model::onResultMarketTokenClick,
onHistoryTokenClick = model::onHistoryTokenClick,
)
}
SearchContent(
@ -86,5 +89,6 @@ internal class DefaultSearchComponent(
data class Params(
val onBackClick: () -> Unit,
val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
)
}

View file

@ -25,7 +25,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

View file

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

View file

@ -31,7 +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.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent

View file

@ -526,7 +526,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,

View file

@ -1,22 +1,27 @@
package com.tangem.features.feed.model.search
import arrow.core.getOrElse
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.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.balancehiding.GetBalanceHidingSettingsUseCase
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.markets.TokenMarketParams
import com.tangem.domain.markets.toSerializableParam
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.model.UserAssetSearchEntry
import com.tangem.domain.search.usecase.SaveSearchQueryUseCase
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.feed.model.market.list.state.MarketsListUM
@ -26,6 +31,7 @@ import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecent
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.converter.UserAssetSearchItemConverter
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.*
@ -35,13 +41,8 @@ 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
@ -54,11 +55,13 @@ internal class SearchModel @Inject constructor(
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,
) : Model() {
@ -77,6 +80,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()
}
@ -143,17 +153,38 @@ internal class SearchModel @Inject constructor(
)
saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input))
saveSearchQueryUseCase(stateController.value.searchBar.query)
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,
)
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,
),
)
}
@ -164,14 +195,19 @@ internal class SearchModel @Inject constructor(
stateController.update(UpdateSearchBarQueryTransformer(query))
}
private fun onActiveChange(isActive: Boolean) {
if (!isActive) params.onBackClick()
}
private fun onClearClick() {
stateController.update(UpdateSearchBarQueryTransformer(""))
}
private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) {
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = entry.userWalletId,
currency = entry.currencyStatus.currency,
),
)
}
private fun subscribeToQueryChanges() {
stateController.uiState
.map { it.searchBar.query.trim() }
@ -200,20 +236,20 @@ 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,
)
searchResult.userAssets
.map(converter::convert)
.toImmutableList()
}.collectLatest { userAssets ->
stateController.update(UpdateUserAssetsTransformer(userAssets))
}
}.saveIn(searchResultsJob)
@ -334,13 +370,6 @@ internal class SearchModel @Inject constructor(
}
}
private fun AccountName.toDisplayString(): String {
return when (this) {
is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize
is AccountName.Custom -> value
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {

View file

@ -0,0 +1,184 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.common.ui.account.toUM
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 kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
internal class UserAssetSearchItemConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onSingleClick: (UserAssetSearchEntry) -> 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) },
)
}
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 children = item.entries.map { entry ->
UserAssetItemUM.GroupedChild(
walletName = entry.userWalletName,
accountName = entry.accountName.toUM(),
accountIcon = entry.accountIcon.value,
accountColor = entry.accountIcon.color,
currencyStatus = entry.currencyStatus,
)
}.toImmutableList()
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,
children = children,
onClick = {},
)
}
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 balance = when {
hasAnyLoading && !hasAnyAmount -> BalanceDisplayState.Loading
hasAnyLoading && hasAnyAmount -> computeGroupBalanceFlickering(entries, symbol, decimals)
hasAnyError && entries.size == 1 && !hasAnyAmount -> BalanceDisplayState.Unreachable
hasAnyError && entries.size > 1 -> computeGroupBalance(entries, symbol, decimals)
else -> computeGroupBalance(entries, symbol, decimals)
}
return balance
}
private fun computeGroupBalance(
entries: List<UserAssetSearchEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState.Loaded {
val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO }
return BalanceDisplayState.Loaded(
cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)),
fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol),
)
}
private fun computeGroupBalanceFlickering(
entries: List<UserAssetSearchEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState.Flickering {
val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO }
val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO }
return BalanceDisplayState.Flickering(
cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)),
fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol),
)
}
private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String {
return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN
}
}

View file

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

View file

@ -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,37 @@ 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),
when (asset) {
is UserAssetItemUM.Single -> SingleUserAssetItem(item = asset)
is UserAssetItemUM.Grouped -> GroupedUserAssetItem(item = asset)
}
}
@Composable
private fun ShowAllUserAssetsButton(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
TangemIcon(
tangemIconUM = TangemIconUM.Url(asset.tokenIconUrl, fallbackRes = R.drawable.ic_custom_token_44),
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
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,
),
)
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,
)
}
}
}
@ -314,7 +376,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod
) {
Text(
text = stringResourceSafe(R.string.markets_search_see_tokens_under_100k),
style = TangemTheme.typography2.bodyRegular14,
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.secondary,
)
TangemButton(

View file

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

View file

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

View file

@ -0,0 +1,117 @@
package com.tangem.features.feed.ui.search.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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(item: UserAssetItemUM.Single, 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 = {
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,
)
PriceBlock(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
priceChangeState = item.priceChangeState,
fiatRate = item.fiatRate,
balanceState = item.balanceState,
)
BalanceColumn(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP),
balanceState = item.balanceState,
isBalanceHidden = item.isBalanceHidden,
)
},
)
}
}
@Composable
private fun PriceBlock(
priceChangeState: PriceChangeState,
fiatRate: String?,
balanceState: BalanceDisplayState,
modifier: Modifier = Modifier,
) {
val isDisabled = remember(balanceState) {
balanceState is BalanceDisplayState.Unreachable
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
if (fiatRate != null) {
Text(
text = fiatRate,
style = TangemTheme.typography2.captionMedium12,
color = if (isDisabled) {
TangemTheme.colors2.text.status.disabled
} else {
TangemTheme.colors2.text.neutral.secondary
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
AnimatedContent(
targetState = priceChangeState,
contentKey = { it::class },
) { animatedState ->
when (animatedState) {
is PriceChangeState.Content -> {
PriceChangeInPercent(
valueInPercent = animatedState.valueInPercent,
type = animatedState.type,
textStyle = TangemTheme.typography2.captionMedium12,
isDisabled = isDisabled,
)
}
PriceChangeState.Unknown -> Unit
}
}
}
}

View file

@ -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,18 +205,28 @@ 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 = {},
)
@ -251,13 +260,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 +394,7 @@ private val SearchContentPreviewCallbacks = SearchCallbacks(
onClearHintsClick = {},
onTextHintClick = { _ -> },
onResultMarketTokenClick = { _ -> },
onHistoryTokenClick = { _ -> },
)
/** All [SearchContentPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */

View file

@ -0,0 +1,238 @@
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.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
import kotlinx.collections.immutable.persistentListOf
/** 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 = {},
)
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,
children = persistentListOf(),
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),
) {
SingleUserAssetItem(item = scenario.item)
}
}
@Composable
private fun GroupedUserAssetItemPreviewHost(
scenario: GroupedUserAssetItemPreviewScenario,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors2.surface.level1)
.padding(8.dp),
) {
GroupedUserAssetItem(item = scenario.item)
}
}
@Composable
@Preview(name = "Single all balance states (parameter)", showBackground = true, widthDp = 360)
@Preview(
name = "Single all balance states (night)",
showBackground = true,
widthDp = 360,
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
private fun SingleUserAssetItemPreview_AllBalanceStates(
@PreviewParameter(SingleUserAssetItemPreviewParameterProvider::class) scenario: SingleUserAssetItemPreviewScenario,
) {
TangemThemePreviewRedesign {
SingleUserAssetItemPreviewHost(scenario = scenario)
}
}
@Composable
@Preview(name = "Grouped all balance states (parameter)", showBackground = true, widthDp = 360)
@Preview(
name = "Grouped all balance states (night)",
showBackground = true,
widthDp = 360,
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
private fun GroupedUserAssetItemPreview_AllBalanceStates(
@PreviewParameter(GroupedUserAssetItemPreviewParameterProvider::class)
scenario: GroupedUserAssetItemPreviewScenario,
) {
TangemThemePreviewRedesign {
GroupedUserAssetItemPreviewHost(scenario = scenario)
}
}

View file

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

View file

@ -1,8 +1,14 @@
package com.tangem.features.feed.ui.search.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountNameUM
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 com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import kotlinx.collections.immutable.ImmutableList
data class SearchUM(
@ -42,11 +48,65 @@ 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,
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,
val children: ImmutableList<GroupedChild>,
override val onClick: () -> Unit,
) : UserAssetItemUM
data class GroupedChild(
val walletName: String,
val accountName: AccountNameUM,
val accountIcon: CryptoPortfolioIcon.Icon,
val accountColor: CryptoPortfolioIcon.Color,
val currencyStatus: CryptoCurrencyStatus,
)
}

View file

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