Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-16 11:00:18 +02:00
parent 7de4f3ab1f
commit 6badbf18b5
24 changed files with 1557 additions and 78 deletions

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M0,8.967C0,7.287 0,6.446 0.327,5.805C0.615,5.24 1.074,4.781 1.638,4.494C2.28,4.167 3.12,4.167 4.8,4.167H10.2C11.88,4.167 12.72,4.167 13.362,4.494C13.927,4.781 14.385,5.24 14.673,5.805C15,6.446 15,7.287 15,8.967V11.033C15,12.714 15,13.554 14.673,14.195C14.385,14.76 13.927,15.219 13.362,15.506C12.72,15.833 11.88,15.833 10.2,15.833H4.8C3.12,15.833 2.28,15.833 1.638,15.506C1.074,15.219 0.615,14.76 0.327,14.195C0,13.554 0,12.714 0,11.033V8.967Z"
android:fillColor="#919191"/>
<path
android:pathData="M0,8.967C0,7.287 0,6.446 0.327,5.805C0.615,5.24 1.074,4.781 1.638,4.494C2.28,4.167 3.12,4.167 4.8,4.167H10.2C11.88,4.167 12.72,4.167 13.362,4.494C13.927,4.781 14.385,5.24 14.673,5.805C15,6.446 15,7.287 15,8.967V11.033C15,12.714 15,13.554 14.673,14.195C14.385,14.76 13.927,15.219 13.362,15.506C12.72,15.833 11.88,15.833 10.2,15.833H4.8C3.12,15.833 2.28,15.833 1.638,15.506C1.074,15.219 0.615,14.76 0.327,14.195C0,13.554 0,12.714 0,11.033V8.967Z"
android:fillColor="#919191"/>
<path
android:pathData="M13.362,4.167C14.81,4.167 15.535,4.167 16.088,4.439C16.575,4.679 16.97,5.061 17.218,5.531C17.5,6.066 17.5,6.766 17.5,8.167V11.833C17.5,13.233 17.5,13.934 17.218,14.469C16.97,14.939 16.575,15.321 16.088,15.561C15.535,15.833 14.81,15.833 13.362,15.833H12.5C13.948,15.833 14.673,15.833 15.226,15.561C15.712,15.321 16.108,14.939 16.356,14.469C16.638,13.934 16.638,13.233 16.638,11.833V8.167C16.638,6.766 16.638,6.066 16.356,5.531C16.108,5.061 15.712,4.679 15.226,4.439C14.673,4.167 13.948,4.167 12.5,4.167H13.362Z"
android:fillColor="#919191"/>
<path
android:pathData="M15.862,4.167C17.31,4.167 18.035,4.167 18.588,4.439C19.075,4.679 19.47,5.061 19.718,5.531C20,6.066 20,6.766 20,8.167V11.833C20,13.233 20,13.934 19.718,14.469C19.47,14.939 19.075,15.321 18.588,15.561C18.035,15.833 17.31,15.833 15.862,15.833H15C16.448,15.833 17.173,15.833 17.726,15.561C18.212,15.321 18.608,14.939 18.856,14.469C19.138,13.934 19.138,13.233 19.138,11.833V8.167C19.138,6.766 19.138,6.066 18.856,5.531C18.608,5.061 18.212,4.679 17.726,4.439C17.173,4.167 16.448,4.167 15,4.167H15.862Z"
android:strokeAlpha="0.6"
android:fillColor="#919191"
android:fillAlpha="0.6"/>
</vector>

View file

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

View file

@ -6,10 +6,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.field.search.TangemFieldShape
import com.tangem.core.ui.ds.field.search.TangemSearchField
@ -29,6 +34,13 @@ internal class DefaultSearchComponent(
private val model = getOrCreateModel<SearchModel, Params>(params = params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = null,
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
@ -69,6 +81,7 @@ internal class DefaultSearchComponent(
contentPadding: PaddingValues,
modifier: Modifier,
) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by model.state.collectAsStateWithLifecycle()
val searchCallbacks = remember {
SearchCallbacks(
@ -85,6 +98,23 @@ internal class DefaultSearchComponent(
searchCallbacks = searchCallbacks,
contentPadding = contentPadding,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: SearchBottomSheetRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = when (config) {
is SearchBottomSheetRoute.TokenSelector -> SearchTokenSelectorComponent(
context = childByContext(componentContext),
params = SearchTokenSelectorComponent.Params(
entries = config.entries,
appCurrency = config.appCurrency,
isBalanceHidden = config.isBalanceHidden,
onTokenSelected = config.onTokenSelected,
onDismiss = config.onDismiss,
),
)
}
data class Params(

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.model.news.list.NewsListModel
import com.tangem.features.feed.model.search.SearchModel
import com.tangem.features.feed.model.search.SearchTokenSelectorModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -71,4 +72,9 @@ internal interface ModelModule {
@IntoMap
@ClassKey(SearchModel::class)
fun provideSearchModel(model: SearchModel): Model
@Binds
@IntoMap
@ClassKey(SearchTokenSelectorModel::class)
fun provideSearchTokenSelectorModel(model: SearchTokenSelectorModel): Model
}

View file

@ -1,6 +1,9 @@
package com.tangem.features.feed.model.search
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.charts.state.MarketChartData
@ -13,28 +16,25 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
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.markets.TokenMarketParams
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.markets.*
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase
import com.tangem.domain.search.usecase.GetSearchResultsUseCase
import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.usecase.SaveSearchQueryUseCase
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.feed.components.search.SearchBottomSheetRoute
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager
import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecentSearchTokenConverter
import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency
import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter
import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency
import com.tangem.features.feed.model.search.converter.UserAssetSearchItemConverter
import com.tangem.features.feed.model.search.converter.*
import com.tangem.features.feed.model.search.state.SearchStateController
import com.tangem.features.feed.model.search.state.transformers.*
import com.tangem.features.feed.ui.search.state.*
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import com.tangem.features.feed.ui.search.state.TextHintItemUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
@ -48,7 +48,7 @@ import javax.inject.Inject
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
private const val MARKET_SEARCH_DEBOUNCE_MS = 500L
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
@ModelScoped
internal class SearchModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
@ -112,6 +112,8 @@ internal class SearchModel @Inject constructor(
)
}
val bottomSheetNavigation: SlotNavigation<SearchBottomSheetRoute> = SlotNavigation()
val state: StateFlow<SearchUM> get() = stateController.uiState
init {
@ -208,6 +210,23 @@ internal class SearchModel @Inject constructor(
)
}
private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) {
bottomSheetNavigation.activate(
SearchBottomSheetRoute.TokenSelector(
entries = grouped.entries,
appCurrency = currentAppCurrency.value,
isBalanceHidden = isBalanceHidden.value,
onTokenSelected = ::onTokenSelectedFromGroup,
onDismiss = { bottomSheetNavigation.dismiss() },
),
)
}
private fun onTokenSelectedFromGroup(entry: UserAssetSearchEntry) {
bottomSheetNavigation.dismiss()
onSingleUserAssetClick(entry)
}
private fun subscribeToQueryChanges() {
stateController.uiState
.map { it.searchBar.query.trim() }
@ -245,6 +264,7 @@ internal class SearchModel @Inject constructor(
appCurrency = appCurrency,
isBalanceHidden = balanceHidden,
onSingleClick = ::onSingleUserAssetClick,
onGroupedClick = ::onGroupedUserAssetClick,
)
searchResult.userAssets
.map(converter::convert)

View file

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

View file

@ -1,6 +1,5 @@
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
@ -22,13 +21,13 @@ 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,
private val onGroupedClick: (UserAssetSearchItem.Grouped) -> Unit,
) : Converter<UserAssetSearchItem, UserAssetItemUM> {
override fun convert(value: UserAssetSearchItem): UserAssetItemUM {
@ -64,6 +63,7 @@ internal class UserAssetSearchItemConverter(
balanceState = convertSingleBalanceState(value, currency.symbol, currency.decimals),
isBalanceHidden = isBalanceHidden,
onClick = { onSingleClick(entry) },
networkName = entry.currencyStatus.currency.network.name,
)
}
@ -101,15 +101,6 @@ internal class UserAssetSearchItemConverter(
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
@ -128,8 +119,7 @@ internal class UserAssetSearchItemConverter(
tokensCount = item.entries.size,
balanceState = convertGroupedBalanceState(item.entries, firstCurrency.symbol, firstCurrency.decimals),
isBalanceHidden = isBalanceHidden,
children = children,
onClick = {},
onClick = { onGroupedClick(item) },
)
}
@ -141,10 +131,12 @@ internal class UserAssetSearchItemConverter(
val hasAnyLoading = entries.any { it.currencyStatus.value is CryptoCurrencyStatus.Loading }
val hasAnyError = entries.any { it.currencyStatus.value.isError }
val hasAnyAmount = entries.any { it.currencyStatus.value.amount != null }
val isAllError = entries.all { it.currencyStatus.value.isError }
val balance = when {
hasAnyLoading && !hasAnyAmount -> BalanceDisplayState.Loading
hasAnyLoading && hasAnyAmount -> computeGroupBalanceFlickering(entries, symbol, decimals)
isAllError -> BalanceDisplayState.Unreachable
hasAnyError && entries.size == 1 && !hasAnyAmount -> BalanceDisplayState.Unreachable
hasAnyError && entries.size > 1 -> computeGroupBalance(entries, symbol, decimals)
else -> computeGroupBalance(entries, symbol, decimals)

View file

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

View file

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

View file

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

View file

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

View file

@ -329,7 +329,14 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) {
@Composable
private fun UserAssetItem(asset: UserAssetItemUM) {
when (asset) {
is UserAssetItemUM.Single -> SingleUserAssetItem(item = asset)
is UserAssetItemUM.Single -> Box(
modifier = Modifier.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
) {
SingleUserAssetItem(item = asset, shouldUsePriceBlock = true)
}
is UserAssetItemUM.Grouped -> GroupedUserAssetItem(item = asset)
}
}

View file

@ -1,10 +1,8 @@
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
@ -23,48 +21,52 @@ 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,
)
fun SingleUserAssetItem(shouldUsePriceBlock: Boolean, item: UserAssetItemUM.Single, modifier: Modifier = Modifier) {
TangemRowContainer(
modifier = modifier.clickable(onClick = item.onClick),
content = {
TangemIcon(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.HEAD)
.size(40.dp)
.padding(end = TangemTheme.dimens2.x1),
tangemIconUM = item.icon,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = item.tokenName,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = item.tokenName,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
if (shouldUsePriceBlock) {
PriceBlock(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
priceChangeState = item.priceChangeState,
fiatRate = item.fiatRate,
balanceState = item.balanceState,
)
BalanceColumn(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP),
balanceState = item.balanceState,
isBalanceHidden = item.isBalanceHidden,
} else {
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = item.networkName,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
},
)
}
}
BalanceColumn(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP),
balanceState = item.balanceState,
isBalanceHidden = item.isBalanceHidden,
)
},
)
}
@Composable

View file

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

View file

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

View file

@ -228,6 +228,7 @@ internal object SearchContentPreviewFixtures {
),
isBalanceHidden = false,
onClick = {},
networkName = "Ethereum",
)
private fun textHint(text: String): TextHintItemUM = TextHintItemUM(text = text)

View file

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

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@ -22,7 +23,6 @@ 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(
@ -146,6 +146,7 @@ internal object UserAssetItemPreviewFixtures {
balanceState = balanceState,
isBalanceHidden = isBalanceHidden,
onClick = {},
networkName = "Ethereum",
)
private fun grouped(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Grouped =
@ -157,7 +158,6 @@ internal object UserAssetItemPreviewFixtures {
tokensCount = 3,
balanceState = balanceState,
isBalanceHidden = isBalanceHidden,
children = persistentListOf(),
onClick = {},
)
}
@ -185,7 +185,14 @@ private fun SingleUserAssetItemPreviewHost(
.background(TangemTheme.colors2.surface.level1)
.padding(8.dp),
) {
SingleUserAssetItem(item = scenario.item)
Box(
modifier = Modifier.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
) {
SingleUserAssetItem(item = scenario.item, shouldUsePriceBlock = true)
}
}
}

View file

@ -1,14 +1,11 @@
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(
@ -87,6 +84,7 @@ sealed interface UserAssetItemUM {
val priceChangeState: PriceChangeState,
val balanceState: BalanceDisplayState,
val isBalanceHidden: Boolean,
val networkName: String,
override val onClick: () -> Unit,
) : UserAssetItemUM
@ -98,15 +96,6 @@ sealed interface UserAssetItemUM {
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

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

View file

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

View file

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