diff --git a/core/ui/src/main/res/drawable/ic_key_card_20.xml b/core/ui/src/main/res/drawable/ic_key_card_20.xml
new file mode 100644
index 0000000000..1a0274c0cd
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_key_card_20.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts
index f5d80f4224..7d4dc84f32 100644
--- a/features/feed/impl/build.gradle.kts
+++ b/features/feed/impl/build.gradle.kts
@@ -9,7 +9,7 @@ plugins {
android {
namespace = "com.tangem.features.feed.impl"
-
+
packaging {
resources {
merges += "paymentrequest.proto"
@@ -17,6 +17,10 @@ android {
}
}
+tasks.withType().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)
}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt
index 677f40ed5a..45392661d4 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt
@@ -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(params = params)
+ private val bottomSheetSlot = childSlot(
+ source = model.bottomSheetNavigation,
+ serializer = null,
+ handleBackButton = false,
+ childFactory = ::bottomSheetChild,
+ )
+
@Composable
override fun Title(bottomSheetState: State) {
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(
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt
new file mode 100644
index 0000000000..7ac509eccf
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt
@@ -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,
+ val appCurrency: AppCurrency,
+ val isBalanceHidden: Boolean,
+ val onTokenSelected: (UserAssetSearchEntry) -> Unit,
+ val onDismiss: () -> Unit,
+ ) : SearchBottomSheetRoute
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt
new file mode 100644
index 0000000000..a36895267e
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt
@@ -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(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,
+ val appCurrency: AppCurrency,
+ val isBalanceHidden: Boolean,
+ val onTokenSelected: (UserAssetSearchEntry) -> Unit,
+ val onDismiss: () -> Unit,
+ )
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt
index 72864031f8..69f12c89b5 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt
@@ -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
}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt
index c55b8f0ec8..839d5877f7 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt
@@ -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 = SlotNavigation()
+
val state: StateFlow 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)
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt
new file mode 100644
index 0000000000..9c8a912394
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt
@@ -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()
+
+ val state: StateFlow
+ get() = stateController.uiState
+
+ init {
+ stateController.update(
+ BuildTokenSelectorSectionsTransformer(
+ entries = params.entries,
+ appCurrency = params.appCurrency,
+ isBalanceHidden = params.isBalanceHidden,
+ onTokenSelected = params.onTokenSelected,
+ ),
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt
index 6f0134ca29..86dda1eb30 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt
@@ -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 {
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)
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt
new file mode 100644
index 0000000000..40effc11de
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt
@@ -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 = MutableStateFlow(
+ value = TokenSelectorContentUM(sections = persistentListOf()),
+ )
+
+ val uiState: StateFlow get() = mutableUiState.asStateFlow()
+
+ fun update(transformer: TokenSelectorUMTransformer) {
+ mutableUiState.update(function = transformer::transform)
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt
new file mode 100644
index 0000000000..a6c2729c0e
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt
@@ -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,
+ 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 {
+ val sections = mutableListOf()
+ 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
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt
new file mode 100644
index 0000000000..b4719f582e
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt
@@ -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 {
+
+ 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
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt
new file mode 100644
index 0000000000..f364f03d07
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt
@@ -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
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt
index 73ab0434d4..33b2589a72 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt
@@ -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)
}
}
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt
index cf255f66a5..dd388ce1c5 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt
@@ -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
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt
new file mode 100644
index 0000000000..36196001f9
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt
@@ -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(
+ 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,
+ ),
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt
new file mode 100644
index 0000000000..1e96099720
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt
@@ -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) {
+ 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,
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt
index 58334e7bce..298dbfbcf1 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt
@@ -228,6 +228,7 @@ internal object SearchContentPreviewFixtures {
),
isBalanceHidden = false,
onClick = {},
+ networkName = "Ethereum",
)
private fun textHint(text: String): TextHintItemUM = TextHintItemUM(text = text)
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt
new file mode 100644
index 0000000000..2a1922d067
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt
@@ -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(
+ 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",
+ )
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt
index f2f8302d45..2c9aecee9d 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt
@@ -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)
+ }
}
}
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt
index 31277562c6..595a24e12b 100644
--- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt
@@ -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,
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,
- )
}
\ No newline at end of file
diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt
new file mode 100644
index 0000000000..3268d7d2c8
--- /dev/null
+++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt
@@ -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,
+) : 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,
+ ) : TokenSelectorSectionUM
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt
new file mode 100644
index 0000000000..5d588ab0d3
--- /dev/null
+++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt
@@ -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()
+ val tokenGroups = result.sections.filterIsInstance()
+
+ 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()
+ 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()
+ 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()
+ val tokenGroups = result.sections.filterIsInstance()
+
+ 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()
+ 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()
+ 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()
+ 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()
+ Truth.assertThat(headers).isEmpty()
+
+ val groups = result.sections.filterIsInstance()
+ Truth.assertThat(groups).hasSize(1)
+ }
+
+ // region Helpers
+
+ private fun createTransformer(entries: List): BuildTokenSelectorSectionsTransformer {
+ return BuildTokenSelectorSectionsTransformer(
+ entries = entries,
+ appCurrency = appCurrency,
+ isBalanceHidden = false,
+ onTokenSelected = onTokenSelected,
+ )
+ }
+
+ private fun createMockUserWalletId(id: String): UserWalletId {
+ return mockk {
+ every { stringValue } returns id
+ }
+ }
+
+ private fun createMockAccountId(id: String): AccountId {
+ return mockk {
+ 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(relaxed = true) {
+ every { name } returns "Network"
+ }
+ val currencyIdObj = mockk {
+ every { value } returns currencyId
+ }
+ val currency = mockk {
+ 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 {
+ 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 {
+ 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
+}
\ No newline at end of file
diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt
new file mode 100644
index 0000000000..9663191563
--- /dev/null
+++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt
@@ -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 {
+ every { stringValue } returns walletId
+ }
+ val accountIdMock = mockk {
+ every { this@mockk.value } returns accountId
+ }
+ val network = mockk(relaxed = true) {
+ every { name } returns networkName
+ }
+ val currencyIdObj = mockk {
+ every { this@mockk.value } returns currencyId
+ }
+ val currency = mockk {
+ 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 {
+ every { this@mockk.currency } returns currency
+ every { this@mockk.value } returns value
+ }
+ return mockk {
+ 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
+}
\ No newline at end of file