From f26ad9136130e056d288143155b6dce0153718dc Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 30 Mar 2026 12:19:24 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../ui/ds/field/search/TangemSearchField.kt | 5 +- .../ui/src/main/res/drawable/ic_return_24.xml | 9 + features/feed/impl/build.gradle.kts | 1 + .../components/DefaultFeedEntryComponent.kt | 16 +- .../feed/components/FeedEntryChildFactory.kt | 28 +- .../components/earn/DefaultEarnComponent.kt | 4 +- .../DefaultMarketsTokenDetailsComponent.kt | 11 +- .../search/DefaultSearchComponent.kt | 73 +++ .../tangem/features/feed/di/ModelModule.kt | 6 + .../features/feed/model/earn/EarnModel.kt | 2 +- .../feed/model/feed/FeedComponentModel.kt | 10 +- .../feed/model/feed/FeedModelClickIntents.kt | 2 + .../details/MarketsTokenDetailsModel.kt | 5 - .../MarketsListBatchFlowManager.kt | 5 + .../features/feed/model/search/SearchModel.kt | 302 ++++++++++ .../search/state/SearchStateController.kt | 43 ++ .../ApplySearchMarketBatchTransformer.kt | 32 ++ .../state/transformers/SearchUMTransformer.kt | 7 + .../SetSearchResultsLoadingTransformer.kt | 35 ++ .../transformers/UpdateHistoryTransformer.kt | 22 + .../UpdateMarketItemsTransformer.kt | 27 + .../UpdateSearchBarQueryTransformer.kt | 14 + .../UpdateUserAssetsTransformer.kt | 27 + .../tangem/features/feed/ui/EntryContent.kt | 36 +- .../feed/ui/components/FeedSearchBar.kt | 2 +- .../feed/ui/feed/components/NewsBlock.kt | 23 +- .../preview/MarketsTokenDetailsPreview.kt | 10 - .../detailed/state/MarketsTokenDetailsUM.kt | 4 +- .../features/feed/ui/search/SearchContent.kt | 348 +++++++++++ .../ui/search/preview/SearchContentPreview.kt | 538 ++++++++++++++++++ .../feed/ui/search/state/SearchCallbacks.kt | 8 + .../features/feed/ui/search/state/SearchUM.kt | 52 ++ .../utils/EntryContentAnimationTransitions.kt | 117 ++-- 33 files changed, 1693 insertions(+), 131 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_return_24.xml create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/SearchStateController.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/ApplySearchMarketBatchTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/SearchUMTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/SetSearchResultsLoadingTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateHistoryTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateMarketItemsTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateSearchBarQueryTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt index 1635ee15f2..4e2e9ebb42 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt @@ -143,7 +143,7 @@ fun TangemSearchField( innerTextField = innerTextField, focusManager = focusManager, interactionSource = interactionSource, - color = TangemTheme.colors2.field.backgroundDefault, + color = TangemTheme.colors2.button.backgroundSecondary, keyboardController = keyboardController, ) }, @@ -288,10 +288,9 @@ private fun CancelButton( if (state.query.isNotEmpty()) { state.onQueryChange("") } - focusManager.clearFocus() keyboardController?.hide() - state.onActiveChange(false) state.onClearClick() + focusManager.clearFocus() }, ) } diff --git a/core/ui/src/main/res/drawable/ic_return_24.xml b/core/ui/src/main/res/drawable/ic_return_24.xml new file mode 100644 index 0000000000..f63099d1f3 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_return_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index 2e076661b9..41a5739506 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(projects.domain.yieldSupply.models) implementation(projects.domain.yieldSupply) implementation(projects.domain.earn) + implementation(projects.domain.search) /* Compose */ implementation(deps.compose.coil) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 9538938a8c..2576b72a84 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -22,7 +22,6 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.news.model.NewsListConfig -import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent @@ -78,7 +77,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( screenSource = AnalyticsParam.ScreensSources.Token, ) }, - onMarketOpenClick = { onMarketOpenClick(null) }, ), ), ) @@ -135,14 +133,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( } override fun onOpenEarnPage() { - innerRouter.push( - FeedEntryChildFactory.Child.Earn( - params = DefaultEarnComponent.Params( - onBackClick = { onChildBack() }, - onMarketOpenClick = { onMarketOpenClick(null) }, - ), - ), - ) + innerRouter.push(FeedEntryChildFactory.Child.Earn) + } + + override fun openSearch() { + innerRouter.push(FeedEntryChildFactory.Child.Search) } } @@ -244,7 +239,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( paginationConfig = null, ) }, - onMarketOpenClick = { clickIntents.onMarketOpenClick(null) }, ), ) FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 812d8b8cef..b4dd71046e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -7,15 +7,18 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.navigation.Route import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.features.feed.components.earn.DefaultEarnComponent -import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles -import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent +import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.components.news.list.DefaultNewsListComponent +import com.tangem.features.feed.components.news.list.DefaultNewsListComponent.Params +import com.tangem.features.feed.components.search.DefaultSearchComponent +import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles +import com.tangem.features.promobanners.api.PromoBannersBlockComponent import kotlinx.serialization.Serializable import javax.inject.Inject @@ -53,7 +56,11 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data class Earn(val params: DefaultEarnComponent.Params) : Child + data object Earn : Child + + @Serializable + @Immutable + data object Search : Child } fun createChild( @@ -86,7 +93,7 @@ internal class FeedEntryChildFactory @Inject constructor( Child.NewsList -> { DefaultNewsListComponent( appComponentContext = appComponentContext, - params = DefaultNewsListComponent.Params( + params = Params( onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig -> feedEntryClickIntents.onArticleClick( articleId = currentArticle, @@ -102,7 +109,7 @@ internal class FeedEntryChildFactory @Inject constructor( Child.Feed -> { DefaultFeedComponent( appComponentContext = appComponentContext, - params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents), + params = FeedParams(feedClickIntents = feedEntryClickIntents), addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, promoBannersBlockComponentFactory = promoBannersBlockComponentFactory, newPromoBannersFeatureToggles = newPromoBannersFeatureToggles, @@ -111,10 +118,19 @@ internal class FeedEntryChildFactory @Inject constructor( is Child.Earn -> { DefaultEarnComponent( appComponentContext = appComponentContext, - params = child.params, + params = DefaultEarnComponent.Params( + onBackClick = onBackClicked, + onSearchClicked = feedEntryClickIntents::openSearch, + ), addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, ) } + Child.Search -> DefaultSearchComponent( + appComponentContext = appComponentContext, + params = DefaultSearchComponent.Params( + onBackClick = onBackClicked, + ), + ) } } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index e5915d7103..b37c5b7dc7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -35,7 +35,6 @@ import com.tangem.features.feed.components.market.details.portfolio.add.AddToPor import com.tangem.features.feed.model.earn.EarnModel import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.earn.EarnContent -import kotlinx.serialization.Serializable internal class DefaultEarnComponent( appComponentContext: AppComponentContext, @@ -129,9 +128,8 @@ internal class DefaultEarnComponent( ) } - @Serializable data class Params( val onBackClick: () -> Unit, - val onMarketOpenClick: () -> Unit, + val onSearchClicked: () -> Unit, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index 93292cc527..dcf08d159a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.lifecycle.compose.LifecycleStartEffect @@ -23,6 +22,8 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +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.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled @@ -34,7 +35,6 @@ import com.tangem.features.feed.components.market.details.portfolio.api.MarketsP import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.state.TokenNetworksState -import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar import kotlinx.coroutines.flow.collectLatest @@ -100,10 +100,7 @@ internal class DefaultMarketsTokenDetailsComponent( val state by model.state.collectAsStateWithLifecycle() val background = LocalMainBottomSheetColor.current.value if (LocalRedesignEnabled.current) { - FeedSearchBar( - isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED, - feedListSearchBar = state.feedListSearchBar, - modifier = Modifier.drawBehind { drawRect(background) }, + TangemTopBar( startContent = { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28), @@ -140,6 +137,7 @@ internal class DefaultMarketsTokenDetailsComponent( .padding(TangemTheme.dimens2.x2_5), ) }, + type = TangemTopBarType.BottomSheet, ) } else { MarketsTokenDetailsTopBar( @@ -188,7 +186,6 @@ internal class DefaultMarketsTokenDetailsComponent( val analyticsParams: AnalyticsParams?, val onBackClicked: () -> Unit, val onArticleClick: (articleId: Int, preselectedArticlesId: List) -> Unit, - val onMarketOpenClick: () -> Unit, ) @Serializable 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 new file mode 100644 index 0000000000..b9cf5d8787 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt @@ -0,0 +1,73 @@ +package com.tangem.features.feed.components.search + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +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.state.BottomSheetState +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.ds.field.search.TangemFieldShape +import com.tangem.core.ui.ds.field.search.TangemSearchField +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.features.feed.model.search.SearchModel +import com.tangem.features.feed.ui.search.SearchContent +import com.tangem.features.feed.ui.search.state.SearchCallbacks + +internal class DefaultSearchComponent( + appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { + + private val model = getOrCreateModel(params = params) + + @Composable + override fun Title(bottomSheetState: State) { + val state by model.state.collectAsStateWithLifecycle() + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(bottomSheetState.value) { + if (bottomSheetState.value == BottomSheetState.EXPANDED) { + focusRequester.requestFocus() + } + } + + TangemTopBar( + type = TangemTopBarType.BottomSheet, + reserveSlotSpace = false, + content = { + TangemSearchField( + state = state.searchBar, + shape = TangemFieldShape.Circle, + focusRequester = focusRequester, + modifier = Modifier.weight(1f), + enabled = bottomSheetState.value == BottomSheetState.EXPANDED, + ) + }, + ) + } + + @Composable + override fun Content(bottomSheetState: State, modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + val searchCallbacks = remember { + SearchCallbacks( + onLoadMore = model::loadMore, + onClearHintsClick = model::clearSearchHistory, + onTextHintClick = model::onTextHintClick, + onResultMarketTokenClick = model::onResultMarketTokenClick, + ) + } + SearchContent( + modifier = modifier, + content = state.content, + searchCallbacks = searchCallbacks, + ) + } + + data class Params( + val onBackClick: () -> 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 2fee6f7d0b..72864031f8 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 @@ -11,6 +11,7 @@ import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel 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 dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -65,4 +66,9 @@ internal interface ModelModule { @IntoMap @ClassKey(EarnTypeFilterModel::class) fun provideEarnTypeFilterModel(model: EarnTypeFilterModel): Model + + @Binds + @IntoMap + @ClassKey(SearchModel::class) + fun provideSearchModel(model: SearchModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index 71a2894d6b..ac71423894 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -325,7 +325,7 @@ internal class EarnModel @Inject constructor( onNetworkFilterClick = ::onNetworkFilterClick, onTypeFilterClick = ::onTypeFilterClick, onScroll = ::onMostlyUsedScrolled, - onSearchBarClicked = params.onMarketOpenClick, + onSearchBarClicked = params.onSearchClicked, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 3252a220e0..33fc8bb216 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -66,6 +67,7 @@ internal class FeedComponentModel @Inject constructor( private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase, private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase, private val appRouter: AppRouter, + private val designFeatureToggles: DesignFeatureToggles, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, paramsContainer: ParamsContainer, @@ -233,7 +235,7 @@ internal class FeedComponentModel @Inject constructor( currentDate = getCurrentDate(), feedListSearchBar = FeedListSearchBar( placeholderText = resourceReference( - id = if (feedFeatureToggle.isEarnBlockEnabled) { + id = if (designFeatureToggles.isRedesignEnabled) { R.string.markets_search_title_placeholder } else { R.string.markets_search_header_title @@ -241,7 +243,11 @@ internal class FeedComponentModel @Inject constructor( ), onBarClick = { analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) - params.feedClickIntents.onMarketOpenClick(null) + if (designFeatureToggles.isRedesignEnabled) { + params.feedClickIntents.openSearch() + } else { + params.feedClickIntents.onMarketOpenClick(null) + } }, ), feedListCallbacks = FeedListCallbacks( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index b9778a2ed6..96ff7cc2f1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -29,4 +29,6 @@ internal interface FeedModelClickIntents { fun onOpenAllNews() fun onOpenEarnPage() + + fun openSearch() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index eb07937be6..b92a688326 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -53,7 +53,6 @@ import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoCo import com.tangem.features.feed.model.market.details.formatter.* import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater import com.tangem.features.feed.model.market.details.state.TokenNetworksState -import com.tangem.features.feed.ui.feed.state.FeedListSearchBar import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM import com.tangem.lib.crypto.BlockchainUtils @@ -260,10 +259,6 @@ internal class MarketsTokenDetailsModel @Inject constructor( onScroll = {}, ), onShareClick = ::onShareClick, - feedListSearchBar = FeedListSearchBar( - onBarClick = { params.onMarketOpenClick() }, - placeholderText = resourceReference(id = R.string.markets_search_title_placeholder), - ), ), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt index b78cb4a6e8..781dceeb6d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -75,6 +75,11 @@ internal class MarketsListBatchFlowManager( private val resultBatches = MutableStateFlow(ResultBatches()) private val uiBatches = stateManager.state.map { it.uiBatches } + val rawItems: Flow> + get() = batchFlow.state + .map { state -> state.data.flatMap { batch -> batch.data } } + .distinctUntilChanged() + val uiItems: StateFlow> get() = uiBatches .map { batches -> 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 new file mode 100644 index 0000000000..fb91bf9fd9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt @@ -0,0 +1,302 @@ +package com.tangem.features.feed.model.search + +import arrow.core.getOrElse +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.search.model.RecentSearchToken +import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase +import com.tangem.domain.search.usecase.GetSearchResultsUseCase +import com.tangem.domain.search.usecase.SaveSearchQueryUseCase +import com.tangem.features.feed.components.search.DefaultSearchComponent +import com.tangem.features.feed.model.market.list.state.MarketsListUM +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.state.SearchStateController +import com.tangem.features.feed.model.search.state.transformers.* +import com.tangem.features.feed.ui.search.state.* +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L + +@Suppress("LongParameterList") +@ModelScoped +internal class SearchModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getSearchResultsUseCase: GetSearchResultsUseCase, + private val saveSearchQueryUseCase: SaveSearchQueryUseCase, + private val clearSearchHistoryUseCase: ClearSearchHistoryUseCase, + private val stateController: SearchStateController, +) : Model() { + + private val params = paramsContainer.require() + + private val updateQuotesJob = JobHolder() + private val searchResultsJob = JobHolder() + private var shouldShowAllTokensIncludingUnder100k = false + + private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val searchMarketsListManager by lazy { + MarketsListBatchFlowManager( + getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, + batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, + currentAppCurrency = Provider { currentAppCurrency.value }, + currentTrendInterval = Provider { MarketsListUM.TrendInterval.H24 }, + currentSortByType = Provider { SortByTypeUM.Rating }, + currentSearchText = Provider { stateController.value.searchBar.query }, + modelScope = modelScope, + dispatchers = dispatchers, + ) + } + + val state: StateFlow get() = stateController.uiState + + init { + initCallbacks() + subscribeToQueryChanges() + subscribeToMarketUiItems() + subscribeToQuotesPolling() + subscribeToAppCurrencyChanges() + loadHistory() + } + + fun loadMore() { + val content = stateController.value.content + if (content is SearchContentUM.Results) { + val market = content.marketTokens + if (market is MarketSearchResultUM.Content) { + searchMarketsListManager.loadMore() + } + } + } + + fun clearSearchHistory() { + modelScope.launch(dispatchers.default) { + clearSearchHistoryUseCase() + } + } + + fun onTextHintClick(text: String) { + stateController.update(UpdateSearchBarQueryTransformer(text)) + } + + fun onResultMarketTokenClick() { + modelScope.launch(dispatchers.default) { + saveSearchQueryUseCase(stateController.value.searchBar.query) + } + } + + private fun initCallbacks() { + stateController.update(object : SearchUMTransformer { + override fun transform(prevState: SearchUM): SearchUM { + return prevState.copy( + searchBar = prevState.searchBar.copy( + onQueryChange = ::onQueryChange, + onActiveChange = ::onActiveChange, + onClearClick = ::onClearClick, + ), + ) + } + }) + } + + private fun onQueryChange(query: String) { + stateController.update(UpdateSearchBarQueryTransformer(query)) + } + + private fun onActiveChange(isActive: Boolean) { + if (!isActive) params.onBackClick() + } + + private fun onClearClick() { + stateController.update(UpdateSearchBarQueryTransformer("")) + } + + private fun subscribeToQueryChanges() { + stateController.uiState + .map { it.searchBar.query.trim() } + .distinctUntilChanged() + .onEach { query -> + shouldShowAllTokensIncludingUnder100k = false + if (query.isEmpty()) { + searchMarketsListManager.clearStateAndStopAllActions() + updateQuotesJob.cancel() + loadHistory() + } else { + stateController.update(SetSearchResultsLoadingTransformer()) + searchMarketsListManager.reload(searchText = query) + subscribeToSearchResults(query) + } + } + .launchIn(modelScope) + } + + private fun subscribeToSearchResults(query: String) { + modelScope.launch { + getSearchResultsUseCase( + query = query, + marketTokens = searchMarketsListManager.rawItems, + ).collectLatest { searchResult -> + val userAssets = searchResult.userAssets.map { entry -> + UserAssetItemUM( + id = "${entry.userWalletId.stringValue}_${entry.accountId.value}" + + "_${entry.currencyStatus.currency.id.value}", + tokenIconUrl = entry.currencyStatus.currency.iconUrl, + tokenName = entry.currencyStatus.currency.name, + tokenSymbol = entry.currencyStatus.currency.symbol, + accountName = entry.accountName.toDisplayString(), + onClick = { + // TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task. + }, + ) + }.toImmutableList() + stateController.update(UpdateUserAssetsTransformer(userAssets)) + } + }.saveIn(searchResultsJob) + } + + private fun subscribeToQuotesPolling() { + searchMarketsListManager.onLastBatchLoadedSuccess.onEach { batchKey -> + searchMarketsListManager.loadCharts(setOf(batchKey), MarketsListUM.TrendInterval.H24) + modelScope.loadQuotesWithTimer(UPDATE_QUOTES_TIMER_MILLIS) + }.launchIn(modelScope) + } + + private fun subscribeToAppCurrencyChanges() { + currentAppCurrency.drop(1).onEach { + val query = stateController.value.searchBar.query + if (query.isNotEmpty()) { + searchMarketsListManager.reload() + } + }.launchIn(modelScope) + } + + private fun subscribeToMarketUiItems() { + combine( + flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(), + flow2 = searchMarketsListManager.uiItems, + flow3 = searchMarketsListManager.isSearchNotFoundState, + flow4 = searchMarketsListManager.isInInitialLoadingErrorState, + ) { query, uiItems, isSearchNotFound, isInErrorState -> + if (query.isEmpty()) return@combine null + val marketResult = when { + isSearchNotFound -> MarketSearchResultUM.NotFound + isInErrorState -> MarketSearchResultUM.NotFound + uiItems.isEmpty() -> MarketSearchResultUM.Empty + else -> buildMarketContent(uiItems) + } + SearchMarketBatchUiSnapshot( + marketResult = marketResult, + isSearchNotFound = isSearchNotFound, + isInErrorState = isInErrorState, + ) + } + .filterNotNull() + .onEach { snapshot -> + stateController.update(ApplySearchMarketBatchTransformer(snapshot)) + } + .launchIn(modelScope) + } + + private fun buildMarketContent(allItems: ImmutableList): MarketSearchResultUM.Content { + if (shouldShowAllTokensIncludingUnder100k) { + return MarketSearchResultUM.Content(items = allItems) + } + + val filtered = allItems.filter { !it.isUnder100kMarketCap }.toImmutableList() + val hasUnder100k = filtered.size != allItems.size + + return if (hasUnder100k) { + MarketSearchResultUM.Content( + items = filtered, + shouldShowUnder100kNotification = true, + onShowUnder100kClick = { + shouldShowAllTokensIncludingUnder100k = true + stateController.update( + UpdateMarketItemsTransformer( + MarketSearchResultUM.Content(items = allItems), + ), + ) + }, + ) + } else { + MarketSearchResultUM.Content(items = allItems) + } + } + + private fun loadHistory() { + modelScope.launch { + getSearchResultsUseCase(query = "").collectLatest { searchResult -> + val textHints = searchResult.textHints.map { hint -> + TextHintItemUM(text = hint.text) + }.toImmutableList() + + val recentTokens = searchResult.recentTokens.map { token -> + token.toMarketsListItemUM() + }.toImmutableList() + + stateController.update(UpdateHistoryTransformer(textHints, recentTokens)) + } + }.saveIn(searchResultsJob) + } + + private fun RecentSearchToken.toMarketsListItemUM(): MarketsListItemUM { + return MarketsListItemUM( + id = id, + name = name, + currencySymbol = symbol, + iconUrl = imageUrl, + ratingPosition = null, + marketCap = null, + price = MarketsListItemUM.Price(text = ""), + trendPercentText = "", + trendType = PriceChangeType.NEUTRAL, + chartData = null, + isUnder100kMarketCap = false, + stakingRate = null, + updateTimestamp = timestamp, + ) + } + + private fun AccountName.toDisplayString(): String { + return when (this) { + is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize + is AccountName.Custom -> value + } + } + + private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { + launch { + while (true) { + delay(timeMillis) + searchMarketsListManager.updateQuotes() + } + }.saveIn(updateQuotesJob) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/SearchStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/SearchStateController.kt new file mode 100644 index 0000000000..ceccae14fa --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/SearchStateController.kt @@ -0,0 +1,43 @@ +package com.tangem.features.feed.model.search.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.model.search.state.transformers.SearchUMTransformer +import com.tangem.features.feed.ui.search.state.SearchContentUM +import com.tangem.features.feed.ui.search.state.SearchUM +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 SearchStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow = mutableUiState.asStateFlow() + + val value: SearchUM + get() = uiState.value + + fun update(transformer: SearchUMTransformer) { + mutableUiState.update(function = transformer::transform) + } + + private fun getInitialState(): SearchUM { + return SearchUM( + searchBar = SearchBarUM( + placeholderText = resourceReference(id = R.string.markets_search_title_placeholder), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + onClearClick = {}, + ), + content = SearchContentUM.InitialEmpty, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/ApplySearchMarketBatchTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/ApplySearchMarketBatchTransformer.kt new file mode 100644 index 0000000000..41dde90476 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/ApplySearchMarketBatchTransformer.kt @@ -0,0 +1,32 @@ +package com.tangem.features.feed.model.search.state.transformers + +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 + +internal class ApplySearchMarketBatchTransformer( + private val snapshot: SearchMarketBatchUiSnapshot, +) : SearchUMTransformer { + + override fun transform(prevState: SearchUM): SearchUM { + if (shouldIgnoreTransientMarketEmpty(prevState, snapshot)) return prevState + return UpdateMarketItemsTransformer(snapshot.marketResult).transform(prevState) + } + + private fun shouldIgnoreTransientMarketEmpty(prevState: SearchUM, snapshot: SearchMarketBatchUiSnapshot): Boolean { + if (snapshot.marketResult !is MarketSearchResultUM.Empty) return false + if (snapshot.isSearchNotFound || snapshot.isInErrorState) return false + val prev = prevState.content as? SearchContentUM.Results ?: return false + return when (prev.marketTokens) { + is MarketSearchResultUM.Content, is MarketSearchResultUM.Loading -> true + else -> false + } + } +} + +/** Inputs from the search market list flow before merging into [SearchUM]. */ +internal data class SearchMarketBatchUiSnapshot( + val marketResult: MarketSearchResultUM, + val isSearchNotFound: Boolean, + val isInErrorState: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/SearchUMTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/SearchUMTransformer.kt new file mode 100644 index 0000000000..e39fd493b9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/SearchUMTransformer.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.features.feed.ui.search.state.SearchUM + +internal interface SearchUMTransformer { + fun transform(prevState: SearchUM): SearchUM +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/SetSearchResultsLoadingTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/SetSearchResultsLoadingTransformer.kt new file mode 100644 index 0000000000..480dd0574a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/SetSearchResultsLoadingTransformer.kt @@ -0,0 +1,35 @@ +package com.tangem.features.feed.model.search.state.transformers + +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 kotlinx.collections.immutable.persistentListOf + +internal class SetSearchResultsLoadingTransformer : SearchUMTransformer { + + override fun transform(prevState: SearchUM): SearchUM { + val currentContent = prevState.content + val currentUserAssets = if (currentContent is SearchContentUM.Results) { + currentContent.userAssets + } else { + persistentListOf() + } + // Keep showing the previous market list while the new query loads (stale-while-revalidate). + // Replacing with Loading + empty batch flashes skeletons / empty section even when the API + // returns the same tokens for a refined query. + val nextMarketTokens = if (currentContent is SearchContentUM.Results) { + when (val market = currentContent.marketTokens) { + is MarketSearchResultUM.Content -> market + else -> MarketSearchResultUM.Loading + } + } else { + MarketSearchResultUM.Loading + } + return prevState.copy( + content = SearchContentUM.Results( + userAssets = currentUserAssets, + marketTokens = nextMarketTokens, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateHistoryTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateHistoryTransformer.kt new file mode 100644 index 0000000000..ec829bf58d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateHistoryTransformer.kt @@ -0,0 +1,22 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.common.ui.markets.models.MarketsListItemUM +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 kotlinx.collections.immutable.ImmutableList + +internal class UpdateHistoryTransformer( + private val textHints: ImmutableList, + private val recentTokens: ImmutableList, +) : SearchUMTransformer { + + override fun transform(prevState: SearchUM): SearchUM { + return prevState.copy( + content = SearchContentUM.History( + textHints = textHints, + recentTokens = recentTokens, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateMarketItemsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateMarketItemsTransformer.kt new file mode 100644 index 0000000000..00e460055f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateMarketItemsTransformer.kt @@ -0,0 +1,27 @@ +package com.tangem.features.feed.model.search.state.transformers + +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 kotlinx.collections.immutable.persistentListOf + +internal class UpdateMarketItemsTransformer( + private val marketResult: MarketSearchResultUM, +) : SearchUMTransformer { + + override fun transform(prevState: SearchUM): SearchUM { + val currentContent = prevState.content + + val currentUserAssets = if (currentContent is SearchContentUM.Results) { + currentContent.userAssets + } else { + persistentListOf() + } + return prevState.copy( + content = SearchContentUM.Results( + userAssets = currentUserAssets, + marketTokens = marketResult, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateSearchBarQueryTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateSearchBarQueryTransformer.kt new file mode 100644 index 0000000000..f1dc142848 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateSearchBarQueryTransformer.kt @@ -0,0 +1,14 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.features.feed.ui.search.state.SearchUM + +internal class UpdateSearchBarQueryTransformer( + private val query: String, +) : SearchUMTransformer { + + override fun transform(prevState: SearchUM): SearchUM { + return prevState.copy( + searchBar = prevState.searchBar.copy(query = query), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt new file mode 100644 index 0000000000..d99fdf0e7c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt @@ -0,0 +1,27 @@ +package com.tangem.features.feed.model.search.state.transformers + +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.UserAssetItemUM +import kotlinx.collections.immutable.ImmutableList + +internal class UpdateUserAssetsTransformer( + private val userAssets: ImmutableList, +) : SearchUMTransformer { + + override fun transform(prevState: SearchUM): SearchUM { + val currentContent = prevState.content + val currentMarketTokens = if (currentContent is SearchContentUM.Results) { + currentContent.marketTokens + } else { + MarketSearchResultUM.Loading + } + return prevState.copy( + content = SearchContentUM.Results( + userAssets = userAssets, + marketTokens = currentMarketTokens, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt index a8efdab5d9..4f1527ad16 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -1,5 +1,6 @@ package com.tangem.features.feed.ui +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Scaffold @@ -11,16 +12,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp -import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.features.feed.components.FeedEntryChildFactory -import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation -import com.tangem.features.feed.ui.utils.topBarFeedEntryStackAnimation +import com.tangem.features.feed.ui.utils.contentFeedEntryAnimatedContentTransitionSpec +import com.tangem.features.feed.ui.utils.topBarFeedEntryAnimatedContentTransitionSpec +@OptIn(ExperimentalDecomposeApi::class) @Composable internal fun EntryContent( bottomSheetState: State, @@ -30,15 +32,15 @@ internal fun EntryContent( ) { val density = LocalDensity.current val background = LocalMainBottomSheetColor.current.value - val animationContent = remember { contentFeedEntryStackAnimation() } - val animationAppBar = remember { topBarFeedEntryStackAnimation() } + val animationContent = remember(stackState) { contentFeedEntryAnimatedContentTransitionSpec(stackState) } + val animationAppBar = remember(stackState) { topBarFeedEntryAnimatedContentTransitionSpec(stackState) } Surface(contentColor = background) { Scaffold( containerColor = background, contentWindowInsets = WindowInsetsZero, topBar = { - Children( + AnimatedContent( modifier = Modifier .then( if (!isOpenedInBottomSheet) { @@ -54,18 +56,22 @@ internal fun EntryContent( } } }, - stack = stackState.value, - animation = animationAppBar, - ) { child -> - child.instance.Title(bottomSheetState) + targetState = stackState.value.active, + transitionSpec = animationAppBar, + contentKey = { it.key }, + label = "FeedEntryAppBar", + ) { state -> + state.instance.Title(bottomSheetState) } }, content = { contentPadding -> - Children( - stack = stackState.value, - animation = animationContent, - ) { child -> - child.instance.Content( + AnimatedContent( + targetState = stackState.value.active, + transitionSpec = animationContent, + contentKey = { it.key }, + label = "FeedEntryContent", + ) { state -> + state.instance.Content( modifier = Modifier.padding(contentPadding), bottomSheetState = bottomSheetState, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/FeedSearchBar.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/FeedSearchBar.kt index 34aa87a805..7d9ab43a62 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/FeedSearchBar.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/FeedSearchBar.kt @@ -107,7 +107,7 @@ private fun FeedSearchBarV2( end = if (endContent != null) TangemTheme.dimens2.x3 else 0.dp, ) .clip(CircleShape) - .background(color = TangemTheme.colors2.field.backgroundDefault) + .background(color = TangemTheme.colors2.button.backgroundSecondary) .conditional(condition = isSearchBarClickable) { clickable(onClick = feedListSearchBar.onBarClick) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt index 4601283941..443eff2ac5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt @@ -17,11 +17,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import com.tangem.features.feed.ui.feed.components.articles.ArticleCard -import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW @@ -31,6 +30,8 @@ import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.feed.components.articles.ArticleCard +import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.features.feed.ui.feed.state.* internal const val FOURTH_ITEM_INDEX = 3 @@ -87,8 +88,16 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, Row(verticalAlignment = Alignment.CenterVertically) { Text( text = stringResourceSafe(R.string.common_news), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, + style = if (isRedesignEnabled) { + TangemTheme.typography2.headingSemibold20 + } else { + TangemTheme.typography.h3 + }, + color = if (isRedesignEnabled) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors.text.primary1 + }, ) SpacerW(4.dp) @@ -113,7 +122,11 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, append(stringResourceSafe(R.string.feed_tangem_ai)) } }, - style = TangemTheme.typography.subtitle1, + style = if (isRedesignEnabled) { + TangemTheme.typography2.bodyRegular16.copy(fontWeight = FontWeight.Medium) + } else { + TangemTheme.typography.subtitle1 + }, overflow = TextOverflow.Ellipsis, maxLines = 1, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt index 65ce49e61e..ed7261d399 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt @@ -5,10 +5,8 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.consumedEvent -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.features.feed.ui.feed.state.FeedListSearchBar import com.tangem.features.feed.ui.market.detailed.state.* import kotlinx.collections.immutable.persistentListOf @@ -52,10 +50,6 @@ internal object MarketsTokenDetailsPreview { onScroll = {}, ), onShareClick = {}, - feedListSearchBar = FeedListSearchBar( - placeholderText = TextReference.Str("Search tokens & news"), - onBarClick = {}, - ), ) val contentState = MarketsTokenDetailsUM( @@ -150,9 +144,5 @@ internal object MarketsTokenDetailsPreview { onScroll = {}, ), onShareClick = {}, - feedListSearchBar = FeedListSearchBar( - placeholderText = TextReference.Str("Search tokens & news"), - onBarClick = {}, - ), ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt index c6e0bde66a..7da2ed065b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt @@ -2,13 +2,12 @@ package com.tangem.features.feed.ui.market.detailed.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.charts.state.MarketChartDataProducer -import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.features.feed.ui.feed.state.FeedListSearchBar +import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal @@ -30,7 +29,6 @@ internal data class MarketsTokenDetailsUM( val onShouldShowPriceSubtitleChange: (Boolean) -> Unit, val relatedNews: RelatedNews, val onShareClick: () -> Unit, - val feedListSearchBar: FeedListSearchBar, ) { data class ChartState( 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 new file mode 100644 index 0000000000..d1f01ad1cc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -0,0 +1,348 @@ +package com.tangem.features.feed.ui.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.markets.MarketsListItem +import com.tangem.common.ui.markets.MarketsListItemPlaceholder +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.list.InfiniteListHandler +import com.tangem.core.ui.ds.button.* +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.search.state.* + +private const val PLACEHOLDER_COUNT = 10 +private const val LOAD_MORE_THRESHOLD = 5 + +@Composable +internal fun SearchContent(content: SearchContentUM, searchCallbacks: SearchCallbacks, modifier: Modifier = Modifier) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + val lazyListState = rememberLazyListState() + + LazyColumn( + state = lazyListState, + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = TangemTheme.dimens2.x4, + end = TangemTheme.dimens2.x4, + bottom = bottomBarHeight, + ), + ) { + when (content) { + is SearchContentUM.InitialEmpty -> Unit + is SearchContentUM.History -> searchHistoryItems( + history = content, + onClearAllClick = searchCallbacks.onClearHintsClick, + onHintClick = searchCallbacks.onTextHintClick, + ) + is SearchContentUM.Results -> searchResultsItems( + results = content, + onResultMarketTokenClick = searchCallbacks.onResultMarketTokenClick, + ) + } + } + if (content is SearchContentUM.Results) { + InfiniteListHandler( + listState = lazyListState, + buffer = LOAD_MORE_THRESHOLD, + triggerLoadMoreCheckOnItemsCountChange = true, + onLoadMore = remember(content.marketTokens) { + { + searchCallbacks.onLoadMore() + true + } + }, + ) + } +} + +private fun LazyListScope.searchHistoryItems( + history: SearchContentUM.History, + onClearAllClick: (() -> Unit), + onHintClick: (String) -> Unit, +) { + if (!history.textHints.isEmpty() || !history.recentTokens.isEmpty()) { + item(key = "recents") { + SectionHeader( + title = stringResourceSafe(R.string.markets_search_hint_header), + onClearAllClick = onClearAllClick, + ) + } + } + items( + items = history.textHints, + key = { "hint_${it.text}" }, + ) { hint -> + TextHintItem(hint = hint, onHintClick = { onHintClick(hint.text) }) + HorizontalDivider( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2), + color = TangemTheme.colors2.border.neutral.primary, + ) + } + items( + items = history.recentTokens, + key = { "recent_${it.getComposeKey()}" }, + ) { token -> + MarketsListItem( + modifier = Modifier + .padding(bottom = TangemTheme.dimens2.x2) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + model = token, + onClick = {}, // TODO in [REDACTED_TASK_KEY] + ) + } +} + +private fun LazyListScope.searchResultsItems(results: SearchContentUM.Results, onResultMarketTokenClick: () -> Unit) { + if (results.userAssets.isNotEmpty()) { + item(key = "header_portfolio") { + SectionHeader(title = stringResourceSafe(R.string.markets_search_portfolio_header)) + } + items( + items = results.userAssets, + key = { it.id }, + ) { asset -> + UserAssetItem(asset) + } + } + + when (val market = results.marketTokens) { + is MarketSearchResultUM.Empty -> Unit + is MarketSearchResultUM.Content -> marketSearchResultItems( + market = market, + hasUserAssetsSection = results.userAssets.isNotEmpty(), + onResultMarketTokenClick = onResultMarketTokenClick, + ) + is MarketSearchResultUM.Loading -> marketSearchResultLoadingItems( + hasUserAssetsSection = results.userAssets.isNotEmpty(), + ) + is MarketSearchResultUM.NotFound -> marketSearchResultNotFoundItem() + } +} + +private fun LazyListScope.marketSearchResultItems( + market: MarketSearchResultUM.Content, + hasUserAssetsSection: Boolean, + onResultMarketTokenClick: () -> Unit, +) { + if (hasUserAssetsSection) { + item(key = "spacer_between_sections") { + SpacerH(TangemTheme.dimens2.x9) + } + } + item(key = "header_market") { + SectionHeader(title = stringResourceSafe(R.string.markets_common_title)) + } + items( + items = market.items, + key = { "market_${it.getComposeKey()}" }, + ) { token -> + MarketsListItem( + modifier = Modifier + .padding(bottom = TangemTheme.dimens2.x2) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + model = token, + onClick = onResultMarketTokenClick, + ) + } + if (market.shouldShowUnder100kNotification) { + item(key = "show_tokens_under_100k") { + ShowTokensUnder100kItem( + onShowTokensClick = market.onShowUnder100kClick, + ) + } + } +} + +private fun LazyListScope.marketSearchResultLoadingItems(hasUserAssetsSection: Boolean) { + if (hasUserAssetsSection) { + item(key = "spacer_between_sections") { + SpacerH(TangemTheme.dimens2.x9) + } + } + item(key = "header_market") { + SectionHeader(title = stringResourceSafe(R.string.markets_common_title)) + } + items(count = PLACEHOLDER_COUNT) { + MarketsListItemPlaceholder() + } +} + +private fun LazyListScope.marketSearchResultNotFoundItem() { + item(key = "not_found") { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResourceSafe(R.string.common_no_results), + style = TangemTheme.typography2.bodyRegular14, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + } + } +} + +@Composable +private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onHintClick) + .padding(vertical = 22.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_search_default_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier.size(TangemTheme.dimens2.x5), + ) + SpacerW(TangemTheme.dimens2.x1) + Text( + modifier = Modifier.weight(1f), + text = hint.text, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + SpacerW(TangemTheme.dimens2.x2) + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_return_24), + contentDescription = null, + tint = TangemTheme.colors2.markers.iconGray, + modifier = Modifier.size(TangemTheme.dimens2.x6), + ) + } +} + +// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task. +@Composable +private fun UserAssetItem(asset: UserAssetItemUM) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = asset.onClick) + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemIcon( + tangemIconUM = TangemIconUM.Url(asset.tokenIconUrl, fallbackRes = R.drawable.ic_custom_token_44), + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = asset.tokenName, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = "${asset.tokenSymbol} · ${asset.accountName}", + style = TangemTheme.typography2.captionRegular13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + ) + } + } +} + +@Composable +private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .padding( + top = TangemTheme.dimens2.x9, + bottom = TangemTheme.dimens2.x3, + start = TangemTheme.dimens2.x10, + end = TangemTheme.dimens2.x10, + ) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResourceSafe(R.string.markets_search_see_tokens_under_100k), + style = TangemTheme.typography2.bodyRegular14, + color = TangemTheme.colors2.text.neutral.secondary, + ) + TangemButton( + buttonUM = TangemButtonUM( + text = resourceReference(R.string.markets_search_show_tokens), + onClick = onShowTokensClick, + type = TangemButtonType.Secondary, + size = TangemButtonSize.X8, + shape = TangemButtonShape.Rounded, + ), + ) + } +} + +@Composable +private fun SectionHeader(title: String, modifier: Modifier = Modifier, onClearAllClick: (() -> Unit)? = null) { + Row( + modifier = modifier + .padding( + vertical = TangemTheme.dimens2.x3, + horizontal = TangemTheme.dimens2.x2, + ) + .padding(bottom = TangemTheme.dimens2.x3), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = title, + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + + if (onClearAllClick != null) { + Text( + modifier = Modifier.clickableSingle(onClick = onClearAllClick), + text = stringResourceSafe(R.string.markets_search_clear_all_hints), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } +} \ 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 new file mode 100644 index 0000000000..227ab6c942 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt @@ -0,0 +1,538 @@ +package com.tangem.features.feed.ui.search.preview + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.ui.search.SearchContent +import com.tangem.features.feed.ui.search.state.* +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +/** Labeled UI state for [SearchContent] previews. */ +internal data class SearchContentPreviewScenario( + val title: String, + val content: SearchContentUM, +) + +/** Sample market rows, portfolio assets, and hints for [SearchContent] previews (callbacks are no-op). */ +@Suppress("MagicNumber", "StringLiteralDuplication", "LargeClass") +internal object SearchContentPreviewFixtures { + + private val chartYSample = persistentListOf( + 0.4, 0.2, 0.45, 0.35, 0.5, 0.55, 0.48, 0.62, 0.58, 0.7, 0.65, 0.8, + ) + + val scenarioInitialEmpty = SearchContentPreviewScenario( + title = "InitialEmpty – blank screen before input", + content = SearchContentUM.InitialEmpty, + ) + + val scenarioHistoryEmptyBoth = SearchContentPreviewScenario( + title = "History – empty hints and recents (no Recents header)", + content = SearchContentUM.History( + textHints = persistentListOf(), + recentTokens = persistentListOf(), + ), + ) + + val scenarioHistoryHintsOnly = SearchContentPreviewScenario( + title = "History – text hints only", + content = SearchContentUM.History( + textHints = hintsSample(), + recentTokens = persistentListOf(), + ), + ) + + val scenarioHistoryRecentsOnly = SearchContentPreviewScenario( + title = "History – recent tokens only", + content = SearchContentUM.History( + textHints = persistentListOf(), + recentTokens = recentsSample(), + ), + ) + + val scenarioHistoryFull = SearchContentPreviewScenario( + title = "History – hints + recents + Clear all", + content = SearchContentUM.History( + textHints = hintsSample(), + recentTokens = recentsSample(), + ), + ) + + val scenarioResultsMarketEmptyNoPortfolio = SearchContentPreviewScenario( + title = "Results – Market.Empty, empty portfolio", + content = SearchContentUM.Results( + userAssets = persistentListOf(), + marketTokens = MarketSearchResultUM.Empty, + ), + ) + + val scenarioResultsMarketEmptyWithPortfolio = SearchContentPreviewScenario( + title = "Results – Market.Empty, with portfolio", + content = SearchContentUM.Results( + userAssets = portfolioTwo(), + marketTokens = MarketSearchResultUM.Empty, + ), + ) + + val scenarioResultsLoadingNoPortfolio = SearchContentPreviewScenario( + title = "Results – Market.Loading, no portfolio", + content = SearchContentUM.Results( + userAssets = persistentListOf(), + marketTokens = MarketSearchResultUM.Loading, + ), + ) + + val scenarioResultsLoadingWithPortfolio = SearchContentPreviewScenario( + title = "Results – Market.Loading + portfolio (spacer + Market header)", + content = SearchContentUM.Results( + userAssets = portfolioTwo(), + marketTokens = MarketSearchResultUM.Loading, + ), + ) + + val scenarioResultsNotFoundNoPortfolio = SearchContentPreviewScenario( + title = "Results – Market.NotFound, no portfolio", + content = SearchContentUM.Results( + userAssets = persistentListOf(), + marketTokens = MarketSearchResultUM.NotFound, + ), + ) + + val scenarioResultsNotFoundWithPortfolio = SearchContentPreviewScenario( + title = "Results – Market.NotFound + portfolio", + content = SearchContentUM.Results( + userAssets = portfolioTwo(), + marketTokens = MarketSearchResultUM.NotFound, + ), + ) + + val scenarioResultsContentMarketOnly = SearchContentPreviewScenario( + title = "Results – Market.Content, market only", + content = SearchContentUM.Results( + userAssets = persistentListOf(), + marketTokens = MarketSearchResultUM.Content( + items = marketListShort(), + shouldShowUnder100kNotification = false, + onShowUnder100kClick = {}, + ), + ), + ) + + val scenarioResultsContentPortfolioAndMarket = SearchContentPreviewScenario( + title = "Results – portfolio + market (no under 100k)", + content = SearchContentUM.Results( + userAssets = portfolioTwo(), + marketTokens = MarketSearchResultUM.Content( + items = marketListShort(), + shouldShowUnder100kNotification = false, + onShowUnder100kClick = {}, + ), + ), + ) + + val scenarioResultsContentWithUnder100kBanner = SearchContentPreviewScenario( + title = "Results – portfolio + market + under 100k banner", + content = SearchContentUM.Results( + userAssets = portfolioTwo(), + marketTokens = MarketSearchResultUM.Content( + items = marketListShort(), + shouldShowUnder100kNotification = true, + onShowUnder100kClick = {}, + ), + ), + ) + + val scenarioResultsLongMarketScroll = SearchContentPreviewScenario( + title = "Results – long market list (scroll)", + content = SearchContentUM.Results( + userAssets = portfolioTwo(), + marketTokens = MarketSearchResultUM.Content( + items = marketListLong(), + shouldShowUnder100kNotification = true, + onShowUnder100kClick = {}, + ), + ), + ) + + private fun marketToken( + rawId: String, + name: String, + symbol: String, + trend: PriceChangeType = PriceChangeType.UP, + priceText: String = "$98,765.43", + trendText: String = "+2.34%", + marketCap: String? = "$1.2 T", + rating: String? = "1", + chart: MarketChartRawData? = MarketChartRawData(y = chartYSample), + staking: Boolean = false, + updateTimestamp: Long = rawId.hashCode().toLong(), + ): MarketsListItemUM = MarketsListItemUM( + id = CryptoCurrency.RawID(rawId), + name = name, + currencySymbol = symbol, + iconUrl = null, + ratingPosition = rating, + marketCap = marketCap, + price = MarketsListItemUM.Price(text = priceText), + trendPercentText = trendText, + trendType = trend, + chartData = chart, + isUnder100kMarketCap = false, + stakingRate = if (staking) stringReference("APY 4.2%") else null, + updateTimestamp = updateTimestamp, + ) + + private fun userAsset( + id: String, + name: String, + symbol: String, + accountName: String, + iconUrl: String? = null, + ): UserAssetItemUM = UserAssetItemUM( + id = id, + tokenIconUrl = iconUrl, + tokenName = name, + tokenSymbol = symbol, + accountName = accountName, + onClick = {}, + ) + + private fun textHint(text: String): TextHintItemUM = TextHintItemUM(text = text) + + private fun hintsSample(): ImmutableList = persistentListOf( + textHint("bitcoin"), + textHint("sol"), + textHint("very long search query example for ellipsis"), + ) + + private fun recentsSample(): ImmutableList = persistentListOf( + marketToken(rawId = "btc_r", name = "Bitcoin", symbol = "BTC"), + marketToken( + rawId = "eth_r", + name = "Ethereum", + symbol = "ETH", + trend = PriceChangeType.DOWN, + trendText = "−1.02%", + rating = "2", + ), + marketToken( + rawId = "long_r", + name = "A Very Long Token Name That Should Ellipsize In The List", + symbol = "LONG", + trend = PriceChangeType.NEUTRAL, + trendText = "0.00%", + marketCap = "$999.123456789 B", + rating = "42", + ), + ) + + private fun portfolioTwo(): ImmutableList = persistentListOf( + userAsset(id = "p1", name = "Ethereum", symbol = "ETH", accountName = "Main wallet"), + userAsset( + id = "p2", + name = "Polygon", + symbol = "POL", + accountName = "Account with a long label for preview", + ), + ) + + private fun marketListShort(): ImmutableList = persistentListOf( + marketToken(rawId = "m1", name = "Bitcoin", symbol = "BTC", rating = "1"), + marketToken( + rawId = "m2", + name = "Ethereum", + symbol = "ETH", + trend = PriceChangeType.DOWN, + trendText = "−0.55%", + rating = "2", + ), + marketToken( + rawId = "m3", + name = "Solana", + symbol = "SOL", + trend = PriceChangeType.NEUTRAL, + trendText = "0.12%", + rating = "3", + ), + ) + + @Suppress("LongMethod") + private fun marketListLong(): ImmutableList = listOf( + marketToken( + rawId = "L1", + name = "Arbitrum", + symbol = "ARB", + trend = PriceChangeType.UP, + priceText = "$1.12", + trendText = "+8.1%", + marketCap = "$3.1 B", + rating = "10", + ), + marketToken( + rawId = "L2", + name = "Optimism", + symbol = "OP", + trend = PriceChangeType.DOWN, + priceText = "$2.34", + trendText = "−3.2%", + marketCap = "$2.8 B", + rating = "11", + ), + marketToken( + rawId = "L3", + name = "Base", + symbol = "—", + trend = PriceChangeType.NEUTRAL, + priceText = "$0.98", + trendText = "0.0%", + marketCap = "$1.9 B", + rating = "12", + chart = null, + ), + marketToken( + rawId = "L4", + name = "Avalanche", + symbol = "AVAX", + trend = PriceChangeType.UP, + priceText = "$36.5", + trendText = "+4.4%", + marketCap = "$14 B", + rating = "13", + staking = true, + ), + marketToken( + rawId = "L5", + name = "Polkadot", + symbol = "DOT", + trend = PriceChangeType.DOWN, + priceText = "$6.12", + trendText = "−2.1%", + marketCap = "$8 B", + rating = "14", + ), + marketToken( + rawId = "L6", + name = "Cosmos", + symbol = "ATOM", + trend = PriceChangeType.UP, + priceText = "$8.90", + trendText = "+1.1%", + marketCap = "$3.4 B", + rating = "15", + ), + marketToken( + rawId = "L7", + name = "Near", + symbol = "NEAR", + trend = PriceChangeType.DOWN, + priceText = "$4.56", + trendText = "−0.8%", + marketCap = "$4.5 B", + rating = "16", + ), + marketToken( + rawId = "L8", + name = "Sui", + symbol = "SUI", + trend = PriceChangeType.UP, + priceText = "$2.10", + trendText = "+12.3%", + marketCap = "$2.2 B", + rating = "17", + ), + ).toImmutableList() + + fun allScenarios(): List = listOf( + scenarioInitialEmpty, + scenarioHistoryEmptyBoth, + scenarioHistoryHintsOnly, + scenarioHistoryRecentsOnly, + scenarioHistoryFull, + scenarioResultsMarketEmptyNoPortfolio, + scenarioResultsMarketEmptyWithPortfolio, + scenarioResultsLoadingNoPortfolio, + scenarioResultsLoadingWithPortfolio, + scenarioResultsNotFoundNoPortfolio, + scenarioResultsNotFoundWithPortfolio, + scenarioResultsContentMarketOnly, + scenarioResultsContentPortfolioAndMarket, + scenarioResultsContentWithUnder100kBanner, + scenarioResultsLongMarketScroll, + ) +} + +private val SearchContentPreviewCallbacks = SearchCallbacks( + onLoadMore = {}, + onClearHintsClick = {}, + onTextHintClick = { _ -> }, + onResultMarketTokenClick = {}, +) + +/** All [SearchContentPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */ +internal class SearchContentPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = SearchContentPreviewFixtures.allScenarios().asSequence() +} + +@Composable +private fun SearchContentPreviewHost( + scenario: SearchContentPreviewScenario, + modifier: Modifier = Modifier, + previewHeightDp: Int = 720, +) { + Box( + modifier = modifier + .height(previewHeightDp.dp) + .fillMaxWidth() + .background(TangemTheme.colors.background.primary), + ) { + SearchContent( + content = scenario.content, + searchCallbacks = SearchContentPreviewCallbacks, + modifier = Modifier.fillMaxSize(), + ) + } +} + +// region Named previews (quick access without cycling parameters) + +@Composable +@Preview(name = "01 Initial empty", showBackground = true, widthDp = 360, heightDp = 720) +@Preview( + name = "01 Initial empty (night)", + showBackground = true, + widthDp = 360, + heightDp = 720, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SearchContentPreview_InitialEmpty() { + TangemThemePreviewRedesign { + SearchContentPreviewHost(scenario = SearchContentPreviewFixtures.scenarioInitialEmpty) + } +} + +@Composable +@Preview(name = "02 History full", showBackground = true, widthDp = 360, heightDp = 720) +@Preview( + name = "02 History full (night)", + showBackground = true, + widthDp = 360, + heightDp = 720, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SearchContentPreview_HistoryFull() { + TangemThemePreviewRedesign { + SearchContentPreviewHost(scenario = SearchContentPreviewFixtures.scenarioHistoryFull) + } +} + +@Composable +@Preview(name = "03 Results loading + portfolio", showBackground = true, widthDp = 360, heightDp = 720) +@Preview( + name = "03 Results loading + portfolio (night)", + showBackground = true, + widthDp = 360, + heightDp = 720, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SearchContentPreview_ResultsLoadingWithPortfolio() { + TangemThemePreviewRedesign { + SearchContentPreviewHost(scenario = SearchContentPreviewFixtures.scenarioResultsLoadingWithPortfolio) + } +} + +@Composable +@Preview(name = "04 Results not found", showBackground = true, widthDp = 360, heightDp = 720) +@Preview( + name = "04 Results not found (night)", + showBackground = true, + widthDp = 360, + heightDp = 720, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SearchContentPreview_ResultsNotFound() { + TangemThemePreviewRedesign { + SearchContentPreviewHost(scenario = SearchContentPreviewFixtures.scenarioResultsNotFoundNoPortfolio) + } +} + +@Composable +@Preview(name = "05 Results market + under 100k", showBackground = true, widthDp = 360, heightDp = 800) +@Preview( + name = "05 Results market + under 100k (night)", + showBackground = true, + widthDp = 360, + heightDp = 800, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SearchContentPreview_ResultsWithUnder100kBanner() { + TangemThemePreviewRedesign { + SearchContentPreviewHost( + scenario = SearchContentPreviewFixtures.scenarioResultsContentWithUnder100kBanner, + previewHeightDp = 800, + ) + } +} + +@Composable +@Preview(name = "06 Results long scroll", showBackground = true, widthDp = 360, heightDp = 640) +@Preview( + name = "06 Results long scroll (night)", + showBackground = true, + widthDp = 360, + heightDp = 640, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SearchContentPreview_ResultsLongList() { + TangemThemePreviewRedesign { + SearchContentPreviewHost( + scenario = SearchContentPreviewFixtures.scenarioResultsLongMarketScroll, + previewHeightDp = 640, + ) + } +} + +// endregion + +/** All scenarios via Preview Parameter; scenario label is the [SearchContentPreviewScenario] title property. */ +@Composable +@Preview(name = "All scenarios (parameter)", showBackground = true, widthDp = 360, heightDp = 720) +@Preview( + name = "All scenarios (parameter, night)", + showBackground = true, + widthDp = 360, + heightDp = 720, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SearchContentPreview_AllScenarios( + @PreviewParameter(SearchContentPreviewParameterProvider::class) scenario: SearchContentPreviewScenario, +) { + TangemThemePreviewRedesign { + SearchContentPreviewHost( + scenario = scenario, + previewHeightDp = when (scenario.title) { + SearchContentPreviewFixtures.scenarioResultsLongMarketScroll.title -> 640 + SearchContentPreviewFixtures.scenarioResultsContentWithUnder100kBanner.title -> 800 + else -> 720 + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt new file mode 100644 index 0000000000..e793512ce1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.ui.search.state + +internal data class SearchCallbacks( + val onLoadMore: () -> Unit, + val onClearHintsClick: () -> Unit, + val onTextHintClick: (hint: String) -> Unit, + val onResultMarketTokenClick: () -> Unit, +) \ No newline at end of file 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 new file mode 100644 index 0000000000..e4628ad30c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt @@ -0,0 +1,52 @@ +package com.tangem.features.feed.ui.search.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import kotlinx.collections.immutable.ImmutableList + +data class SearchUM( + val searchBar: SearchBarUM, + val content: SearchContentUM, +) + +@Immutable +sealed interface SearchContentUM { + + data class History( + val textHints: ImmutableList, + val recentTokens: ImmutableList, + ) : SearchContentUM + + data class Results( + val userAssets: ImmutableList, + val marketTokens: MarketSearchResultUM, + ) : SearchContentUM + + data object InitialEmpty : SearchContentUM +} + +@Immutable +sealed interface MarketSearchResultUM { + + data class Content( + val items: ImmutableList, + val shouldShowUnder100kNotification: Boolean = false, + val onShowUnder100kClick: () -> Unit = {}, + ) : MarketSearchResultUM + + data object Loading : MarketSearchResultUM + data object NotFound : MarketSearchResultUM + data object Empty : MarketSearchResultUM +} + +data class TextHintItemUM(val text: String) + +data class UserAssetItemUM( + val id: String, + val tokenIconUrl: String?, + val tokenName: String, + val tokenSymbol: String, + val accountName: String, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt index 638e28ca11..02de097c9a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt @@ -1,71 +1,70 @@ package com.tangem.features.feed.ui.utils +import androidx.compose.animation.* import androidx.compose.animation.core.tween -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.dp -import com.arkivanov.decompose.FaultyDecomposeApi -import com.arkivanov.decompose.extensions.compose.stack.animation.* +import androidx.compose.runtime.State +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent -import com.tangem.core.ui.utils.toPx import com.tangem.features.feed.components.FeedEntryChildFactory -private const val DELAY_FOR_TRANSITION = 400 +private fun FeedEntryChildFactory.Child?.usesFadeStackTransition(): Boolean = when (this) { + is FeedEntryChildFactory.Child.Search -> true + is FeedEntryChildFactory.Child.TokenList -> params.shouldAlwaysShowSearchBar + else -> false +} -@OptIn(FaultyDecomposeApi::class) -internal fun topBarFeedEntryStackAnimation(): StackAnimation< - FeedEntryChildFactory.Child, - ComposableModularBottomSheetContentComponent, - > = - stackAnimation { to, from, _ -> - val isSearchToTokenList = - (to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true - val isFromSearchTokenList = - (from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true - if (isSearchToTokenList || isFromSearchTokenList) { - fade() - } else { - slide() - } +internal typealias FeedEntryActiveChild = + Child.Created + +internal typealias FeedEntryChildStack = + ChildStack + +internal fun topBarFeedEntryAnimatedContentTransitionSpec( + stackState: State, +): AnimatedContentTransitionScope.() -> ContentTransform = + { feedEntryAnimatedContentTransform(stackState.value) } + +internal fun contentFeedEntryAnimatedContentTransitionSpec( + stackState: State, +): AnimatedContentTransitionScope.() -> ContentTransform = + { feedEntryAnimatedContentTransform(stackState.value) } + +private fun AnimatedContentTransitionScope.feedEntryAnimatedContentTransform( + stack: FeedEntryChildStack, +): ContentTransform { + val shouldUseFade = initialState.configuration.usesFadeStackTransition() || + targetState.configuration.usesFadeStackTransition() + return if (shouldUseFade) { + fadeIn(animationSpec = tween(FEED_ENTRY_FADE_DURATION_MS)) togetherWith + fadeOut(animationSpec = tween(FEED_ENTRY_FADE_DURATION_MS)) + } else { + feedEntrySlideTransform(stack) } +} -@OptIn(FaultyDecomposeApi::class) -internal fun contentFeedEntryStackAnimation(): StackAnimation< - FeedEntryChildFactory.Child, - ComposableModularBottomSheetContentComponent, - > = - stackAnimation { to, from, _ -> - val isSearchToTokenList = - (to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true - val isFromSearchTokenList = - (from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true - if (isSearchToTokenList || isFromSearchTokenList) { - fadeAndSlideInt() - } else { - slide() - } - } - -private fun fadeAndSlideInt(): StackAnimator = - stackAnimator(animationSpec = tween(DELAY_FOR_TRANSITION)) { factor, _, content -> - val alpha = 1f - kotlin.math.abs(factor) - val slidePx = 32.dp.toPx() - val yOffset = when { - factor > 0 -> (slidePx * factor).toInt() - factor < 0 -> (slidePx * factor * -1).toInt() - else -> 0 - } - content( - Modifier - .alpha(alpha) - .offsetY(yOffset), +private fun AnimatedContentTransitionScope.feedEntrySlideTransform( + stack: FeedEntryChildStack, +): ContentTransform { + val isPushing = stack.backStack.lastOrNull()?.configuration == initialState.configuration + return if (isPushing) { + slideInHorizontally( + animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS), + initialOffsetX = { it }, + ) togetherWith slideOutHorizontally( + animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS), + targetOffsetX = { -it }, + ) + } else { + slideInHorizontally( + animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS), + initialOffsetX = { -it }, + ) togetherWith slideOutHorizontally( + animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS), + targetOffsetX = { it }, ) } +} -private fun Modifier.offsetY(pixels: Int): Modifier = layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.placeRelative(x = 0, y = pixels) - } -} \ No newline at end of file +private const val FEED_ENTRY_FADE_DURATION_MS = 300 +private const val FEED_ENTRY_SLIDE_DURATION_MS = 300 \ No newline at end of file