From 6badbf18b5d3c219a35fd49c81c0d99fb7951c4d Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 11:00:18 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../src/main/res/drawable/ic_key_card_20.xml | 20 + features/feed/impl/build.gradle.kts | 12 +- .../search/DefaultSearchComponent.kt | 30 ++ .../search/SearchBottomSheetRoute.kt | 15 + .../search/SearchTokenSelectorComponent.kt | 46 +++ .../tangem/features/feed/di/ModelModule.kt | 6 + .../features/feed/model/search/SearchModel.kt | 46 ++- .../model/search/SearchTokenSelectorModel.kt | 38 ++ .../converter/UserAssetSearchItemConverter.kt | 18 +- .../state/TokenSelectorStateController.kt | 25 ++ .../BuildTokenSelectorSectionsTransformer.kt | 67 ++++ .../TokenSelectorEntryConverter.kt | 98 +++++ .../TokenSelectorUMTransformer.kt | 7 + .../features/feed/ui/search/SearchContent.kt | 9 +- .../search/components/SingleUserAssetItem.kt | 72 ++-- .../components/TokenSelectorBottomSheet.kt | 146 +++++++ .../ui/search/components/TokenSelectorList.kt | 140 +++++++ .../ui/search/preview/SearchContentPreview.kt | 1 + .../TokenSelectorContentPreviewProvider.kt | 122 ++++++ .../ui/search/preview/UserAssetItemPreview.kt | 13 +- .../features/feed/ui/search/state/SearchUM.kt | 13 +- .../feed/ui/search/state/TokenSelectorUM.kt | 29 ++ ...ildTokenSelectorSectionsTransformerTest.kt | 304 +++++++++++++++ .../TokenSelectorEntryConverterTest.kt | 358 ++++++++++++++++++ 24 files changed, 1557 insertions(+), 78 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_key_card_20.xml create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt 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