Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-30 12:19:24 +02:00
parent e8b15413d2
commit f26ad91361
33 changed files with 1693 additions and 131 deletions

View file

@ -143,7 +143,7 @@ fun TangemSearchField(
innerTextField = innerTextField, innerTextField = innerTextField,
focusManager = focusManager, focusManager = focusManager,
interactionSource = interactionSource, interactionSource = interactionSource,
color = TangemTheme.colors2.field.backgroundDefault, color = TangemTheme.colors2.button.backgroundSecondary,
keyboardController = keyboardController, keyboardController = keyboardController,
) )
}, },
@ -288,10 +288,9 @@ private fun CancelButton(
if (state.query.isNotEmpty()) { if (state.query.isNotEmpty()) {
state.onQueryChange("") state.onQueryChange("")
} }
focusManager.clearFocus()
keyboardController?.hide() keyboardController?.hide()
state.onActiveChange(false)
state.onClearClick() state.onClearClick()
focusManager.clearFocus()
}, },
) )
} }

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20,5C20.552,5 21,5.448 21,6V9.596C21,12.374 18.735,14.62 15.956,14.596L7.29,14.519L10.445,17.808C10.827,18.206 10.814,18.839 10.416,19.222C10.017,19.604 9.384,19.591 9.002,19.192L4.33,14.323C4.134,14.118 4.028,13.863 4.005,13.608L4,13.5L4.005,13.392C4.028,13.137 4.134,12.882 4.33,12.677L9.002,7.808C9.384,7.409 10.017,7.396 10.416,7.778C10.814,8.161 10.827,8.794 10.445,9.192L7.252,12.519L15.974,12.596C17.641,12.61 19,11.263 19,9.596V6C19,5.448 19.448,5 20,5Z"
android:fillColor="#919191"/>
</vector>

View file

@ -57,6 +57,7 @@ dependencies {
implementation(projects.domain.yieldSupply.models) implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply) implementation(projects.domain.yieldSupply)
implementation(projects.domain.earn) implementation(projects.domain.earn)
implementation(projects.domain.search)
/* Compose */ /* Compose */
implementation(deps.compose.coil) implementation(deps.compose.coil)

View file

@ -22,7 +22,6 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig 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.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent 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.details.DefaultNewsDetailsComponent
@ -78,7 +77,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
screenSource = AnalyticsParam.ScreensSources.Token, screenSource = AnalyticsParam.ScreensSources.Token,
) )
}, },
onMarketOpenClick = { onMarketOpenClick(null) },
), ),
), ),
) )
@ -135,14 +133,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
} }
override fun onOpenEarnPage() { override fun onOpenEarnPage() {
innerRouter.push( innerRouter.push(FeedEntryChildFactory.Child.Earn)
FeedEntryChildFactory.Child.Earn( }
params = DefaultEarnComponent.Params(
onBackClick = { onChildBack() }, override fun openSearch() {
onMarketOpenClick = { onMarketOpenClick(null) }, innerRouter.push(FeedEntryChildFactory.Child.Search)
),
),
)
} }
} }
@ -244,7 +239,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
paginationConfig = null, paginationConfig = null,
) )
}, },
onMarketOpenClick = { clickIntents.onMarketOpenClick(null) },
), ),
) )
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(

View file

@ -7,15 +7,18 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.features.feed.components.earn.DefaultEarnComponent 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
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.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent 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.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent 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.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent 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 kotlinx.serialization.Serializable
import javax.inject.Inject import javax.inject.Inject
@ -53,7 +56,11 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable @Serializable
@Immutable @Immutable
data class Earn(val params: DefaultEarnComponent.Params) : Child data object Earn : Child
@Serializable
@Immutable
data object Search : Child
} }
fun createChild( fun createChild(
@ -86,7 +93,7 @@ internal class FeedEntryChildFactory @Inject constructor(
Child.NewsList -> { Child.NewsList -> {
DefaultNewsListComponent( DefaultNewsListComponent(
appComponentContext = appComponentContext, appComponentContext = appComponentContext,
params = DefaultNewsListComponent.Params( params = Params(
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig -> onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
feedEntryClickIntents.onArticleClick( feedEntryClickIntents.onArticleClick(
articleId = currentArticle, articleId = currentArticle,
@ -102,7 +109,7 @@ internal class FeedEntryChildFactory @Inject constructor(
Child.Feed -> { Child.Feed -> {
DefaultFeedComponent( DefaultFeedComponent(
appComponentContext = appComponentContext, appComponentContext = appComponentContext,
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents), params = FeedParams(feedClickIntents = feedEntryClickIntents),
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
promoBannersBlockComponentFactory = promoBannersBlockComponentFactory, promoBannersBlockComponentFactory = promoBannersBlockComponentFactory,
newPromoBannersFeatureToggles = newPromoBannersFeatureToggles, newPromoBannersFeatureToggles = newPromoBannersFeatureToggles,
@ -111,10 +118,19 @@ internal class FeedEntryChildFactory @Inject constructor(
is Child.Earn -> { is Child.Earn -> {
DefaultEarnComponent( DefaultEarnComponent(
appComponentContext = appComponentContext, appComponentContext = appComponentContext,
params = child.params, params = DefaultEarnComponent.Params(
onBackClick = onBackClicked,
onSearchClicked = feedEntryClickIntents::openSearch,
),
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
) )
} }
Child.Search -> DefaultSearchComponent(
appComponentContext = appComponentContext,
params = DefaultSearchComponent.Params(
onBackClick = onBackClicked,
),
)
} }
} }
} }

View file

@ -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.model.earn.EarnModel
import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.earn.EarnContent import com.tangem.features.feed.ui.earn.EarnContent
import kotlinx.serialization.Serializable
internal class DefaultEarnComponent( internal class DefaultEarnComponent(
appComponentContext: AppComponentContext, appComponentContext: AppComponentContext,
@ -129,9 +128,8 @@ internal class DefaultEarnComponent(
) )
} }
@Serializable
data class Params( data class Params(
val onBackClick: () -> Unit, val onBackClick: () -> Unit,
val onMarketOpenClick: () -> Unit, val onSearchClicked: () -> Unit,
) )
} }

View file

@ -10,7 +10,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.LifecycleStartEffect 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.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent 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.extensions.clickableSingle
import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled 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.MarketsTokenDetailsModel
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent 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.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.MarketsTokenDetailsContent
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -100,10 +100,7 @@ internal class DefaultMarketsTokenDetailsComponent(
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val background = LocalMainBottomSheetColor.current.value val background = LocalMainBottomSheetColor.current.value
if (LocalRedesignEnabled.current) { if (LocalRedesignEnabled.current) {
FeedSearchBar( TangemTopBar(
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
feedListSearchBar = state.feedListSearchBar,
modifier = Modifier.drawBehind { drawRect(background) },
startContent = { startContent = {
Icon( Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28), imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
@ -140,6 +137,7 @@ internal class DefaultMarketsTokenDetailsComponent(
.padding(TangemTheme.dimens2.x2_5), .padding(TangemTheme.dimens2.x2_5),
) )
}, },
type = TangemTopBarType.BottomSheet,
) )
} else { } else {
MarketsTokenDetailsTopBar( MarketsTokenDetailsTopBar(
@ -188,7 +186,6 @@ internal class DefaultMarketsTokenDetailsComponent(
val analyticsParams: AnalyticsParams?, val analyticsParams: AnalyticsParams?,
val onBackClicked: () -> Unit, val onBackClicked: () -> Unit,
val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit, val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit,
val onMarketOpenClick: () -> Unit,
) )
@Serializable @Serializable

View file

@ -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<SearchModel, Params>(params = params)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
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<BottomSheetState>, 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,
)
}

View file

@ -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.market.list.MarketsListModel
import com.tangem.features.feed.model.news.details.NewsDetailsModel import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.model.news.list.NewsListModel import com.tangem.features.feed.model.news.list.NewsListModel
import com.tangem.features.feed.model.search.SearchModel
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -65,4 +66,9 @@ internal interface ModelModule {
@IntoMap @IntoMap
@ClassKey(EarnTypeFilterModel::class) @ClassKey(EarnTypeFilterModel::class)
fun provideEarnTypeFilterModel(model: EarnTypeFilterModel): Model fun provideEarnTypeFilterModel(model: EarnTypeFilterModel): Model
@Binds
@IntoMap
@ClassKey(SearchModel::class)
fun provideSearchModel(model: SearchModel): Model
} }

View file

@ -325,7 +325,7 @@ internal class EarnModel @Inject constructor(
onNetworkFilterClick = ::onNetworkFilterClick, onNetworkFilterClick = ::onNetworkFilterClick,
onTypeFilterClick = ::onTypeFilterClick, onTypeFilterClick = ::onTypeFilterClick,
onScroll = ::onMostlyUsedScrolled, onScroll = ::onMostlyUsedScrolled,
onSearchBarClicked = params.onMarketOpenClick, onSearchBarClicked = params.onSearchClicked,
), ),
) )
} }

View file

@ -13,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer 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.extensions.resourceReference
import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -66,6 +67,7 @@ internal class FeedComponentModel @Inject constructor(
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase, private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase, private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
private val appRouter: AppRouter, private val appRouter: AppRouter,
private val designFeatureToggles: DesignFeatureToggles,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
@ -233,7 +235,7 @@ internal class FeedComponentModel @Inject constructor(
currentDate = getCurrentDate(), currentDate = getCurrentDate(),
feedListSearchBar = FeedListSearchBar( feedListSearchBar = FeedListSearchBar(
placeholderText = resourceReference( placeholderText = resourceReference(
id = if (feedFeatureToggle.isEarnBlockEnabled) { id = if (designFeatureToggles.isRedesignEnabled) {
R.string.markets_search_title_placeholder R.string.markets_search_title_placeholder
} else { } else {
R.string.markets_search_header_title R.string.markets_search_header_title
@ -241,7 +243,11 @@ internal class FeedComponentModel @Inject constructor(
), ),
onBarClick = { onBarClick = {
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
params.feedClickIntents.onMarketOpenClick(null) if (designFeatureToggles.isRedesignEnabled) {
params.feedClickIntents.openSearch()
} else {
params.feedClickIntents.onMarketOpenClick(null)
}
}, },
), ),
feedListCallbacks = FeedListCallbacks( feedListCallbacks = FeedListCallbacks(

View file

@ -29,4 +29,6 @@ internal interface FeedModelClickIntents {
fun onOpenAllNews() fun onOpenAllNews()
fun onOpenEarnPage() fun onOpenEarnPage()
fun openSearch()
} }

View file

@ -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.formatter.*
import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater 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.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.ExchangesBottomSheetContent
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.lib.crypto.BlockchainUtils import com.tangem.lib.crypto.BlockchainUtils
@ -260,10 +259,6 @@ internal class MarketsTokenDetailsModel @Inject constructor(
onScroll = {}, onScroll = {},
), ),
onShareClick = ::onShareClick, onShareClick = ::onShareClick,
feedListSearchBar = FeedListSearchBar(
onBarClick = { params.onMarketOpenClick() },
placeholderText = resourceReference(id = R.string.markets_search_title_placeholder),
),
), ),
) )

View file

@ -75,6 +75,11 @@ internal class MarketsListBatchFlowManager(
private val resultBatches = MutableStateFlow(ResultBatches()) private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = stateManager.state.map { it.uiBatches } private val uiBatches = stateManager.state.map { it.uiBatches }
val rawItems: Flow<List<TokenMarket>>
get() = batchFlow.state
.map { state -> state.data.flatMap { batch -> batch.data } }
.distinctUntilChanged()
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>> val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
get() = uiBatches get() = uiBatches
.map { batches -> .map { batches ->

View file

@ -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<DefaultSearchComponent.Params>()
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<SearchUM> 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<MarketsListItemUM>): 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)
}
}

View file

@ -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<SearchUM> = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<SearchUM> = 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,
)
}
}

View file

@ -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,
)

View file

@ -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
}

View file

@ -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,
),
)
}
}

View file

@ -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<TextHintItemUM>,
private val recentTokens: ImmutableList<MarketsListItemUM>,
) : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
return prevState.copy(
content = SearchContentUM.History(
textHints = textHints,
recentTokens = recentTokens,
),
)
}
}

View file

@ -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,
),
)
}
}

View file

@ -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),
)
}
}

View file

@ -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<UserAssetItemUM>,
) : 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,
),
)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.feed.ui package com.tangem.features.feed.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -11,16 +12,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp 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.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.feed.components.FeedEntryChildFactory import com.tangem.features.feed.components.FeedEntryChildFactory
import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation import com.tangem.features.feed.ui.utils.contentFeedEntryAnimatedContentTransitionSpec
import com.tangem.features.feed.ui.utils.topBarFeedEntryStackAnimation import com.tangem.features.feed.ui.utils.topBarFeedEntryAnimatedContentTransitionSpec
@OptIn(ExperimentalDecomposeApi::class)
@Composable @Composable
internal fun EntryContent( internal fun EntryContent(
bottomSheetState: State<BottomSheetState>, bottomSheetState: State<BottomSheetState>,
@ -30,15 +32,15 @@ internal fun EntryContent(
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value val background = LocalMainBottomSheetColor.current.value
val animationContent = remember { contentFeedEntryStackAnimation() } val animationContent = remember(stackState) { contentFeedEntryAnimatedContentTransitionSpec(stackState) }
val animationAppBar = remember { topBarFeedEntryStackAnimation() } val animationAppBar = remember(stackState) { topBarFeedEntryAnimatedContentTransitionSpec(stackState) }
Surface(contentColor = background) { Surface(contentColor = background) {
Scaffold( Scaffold(
containerColor = background, containerColor = background,
contentWindowInsets = WindowInsetsZero, contentWindowInsets = WindowInsetsZero,
topBar = { topBar = {
Children( AnimatedContent(
modifier = Modifier modifier = Modifier
.then( .then(
if (!isOpenedInBottomSheet) { if (!isOpenedInBottomSheet) {
@ -54,18 +56,22 @@ internal fun EntryContent(
} }
} }
}, },
stack = stackState.value, targetState = stackState.value.active,
animation = animationAppBar, transitionSpec = animationAppBar,
) { child -> contentKey = { it.key },
child.instance.Title(bottomSheetState) label = "FeedEntryAppBar",
) { state ->
state.instance.Title(bottomSheetState)
} }
}, },
content = { contentPadding -> content = { contentPadding ->
Children( AnimatedContent(
stack = stackState.value, targetState = stackState.value.active,
animation = animationContent, transitionSpec = animationContent,
) { child -> contentKey = { it.key },
child.instance.Content( label = "FeedEntryContent",
) { state ->
state.instance.Content(
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
bottomSheetState = bottomSheetState, bottomSheetState = bottomSheetState,
) )

View file

@ -107,7 +107,7 @@ private fun FeedSearchBarV2(
end = if (endContent != null) TangemTheme.dimens2.x3 else 0.dp, end = if (endContent != null) TangemTheme.dimens2.x3 else 0.dp,
) )
.clip(CircleShape) .clip(CircleShape)
.background(color = TangemTheme.colors2.field.backgroundDefault) .background(color = TangemTheme.colors2.button.backgroundSecondary)
.conditional(condition = isSearchBarClickable) { .conditional(condition = isSearchBarClickable) {
clickable(onClick = feedListSearchBar.onBarClick) clickable(onClick = feedListSearchBar.onBarClick)
} }

View file

@ -17,11 +17,10 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString 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.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp 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.R
import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW 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.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme 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.* import com.tangem.features.feed.ui.feed.state.*
internal const val FOURTH_ITEM_INDEX = 3 internal const val FOURTH_ITEM_INDEX = 3
@ -87,8 +88,16 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = stringResourceSafe(R.string.common_news), text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3, style = if (isRedesignEnabled) {
color = TangemTheme.colors.text.primary1, TangemTheme.typography2.headingSemibold20
} else {
TangemTheme.typography.h3
},
color = if (isRedesignEnabled) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors.text.primary1
},
) )
SpacerW(4.dp) SpacerW(4.dp)
@ -113,7 +122,11 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
append(stringResourceSafe(R.string.feed_tangem_ai)) 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, overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
) )

View file

@ -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.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.markets.PriceChangeInterval 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 com.tangem.features.feed.ui.market.detailed.state.*
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -52,10 +50,6 @@ internal object MarketsTokenDetailsPreview {
onScroll = {}, onScroll = {},
), ),
onShareClick = {}, onShareClick = {},
feedListSearchBar = FeedListSearchBar(
placeholderText = TextReference.Str("Search tokens & news"),
onBarClick = {},
),
) )
val contentState = MarketsTokenDetailsUM( val contentState = MarketsTokenDetailsUM(
@ -150,9 +144,5 @@ internal object MarketsTokenDetailsPreview {
onScroll = {}, onScroll = {},
), ),
onShareClick = {}, onShareClick = {},
feedListSearchBar = FeedListSearchBar(
placeholderText = TextReference.Str("Search tokens & news"),
onBarClick = {},
),
) )
} }

View file

@ -2,13 +2,12 @@ package com.tangem.features.feed.ui.market.detailed.state
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartDataProducer 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.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.markets.PriceChangeInterval 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 kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal import java.math.BigDecimal
@ -30,7 +29,6 @@ internal data class MarketsTokenDetailsUM(
val onShouldShowPriceSubtitleChange: (Boolean) -> Unit, val onShouldShowPriceSubtitleChange: (Boolean) -> Unit,
val relatedNews: RelatedNews, val relatedNews: RelatedNews,
val onShareClick: () -> Unit, val onShareClick: () -> Unit,
val feedListSearchBar: FeedListSearchBar,
) { ) {
data class ChartState( data class ChartState(

View file

@ -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,
)
}
}
}

View file

@ -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<TextHintItemUM> = persistentListOf(
textHint("bitcoin"),
textHint("sol"),
textHint("very long search query example for ellipsis"),
)
private fun recentsSample(): ImmutableList<MarketsListItemUM> = 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<UserAssetItemUM> = persistentListOf(
userAsset(id = "p1", name = "Ethereum", symbol = "ETH", accountName = "Main wallet"),
userAsset(
id = "p2",
name = "Polygon",
symbol = "POL",
accountName = "Account with a long label for preview",
),
)
private fun marketListShort(): ImmutableList<MarketsListItemUM> = 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<MarketsListItemUM> = 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<SearchContentPreviewScenario> = 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<SearchContentPreviewScenario> {
override val values: Sequence<SearchContentPreviewScenario>
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
},
)
}
}

View file

@ -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,
)

View file

@ -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<TextHintItemUM>,
val recentTokens: ImmutableList<MarketsListItemUM>,
) : SearchContentUM
data class Results(
val userAssets: ImmutableList<UserAssetItemUM>,
val marketTokens: MarketSearchResultUM,
) : SearchContentUM
data object InitialEmpty : SearchContentUM
}
@Immutable
sealed interface MarketSearchResultUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
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,
)

View file

@ -1,71 +1,70 @@
package com.tangem.features.feed.ui.utils package com.tangem.features.feed.ui.utils
import androidx.compose.animation.*
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.ui.Modifier import androidx.compose.runtime.State
import androidx.compose.ui.draw.alpha import com.arkivanov.decompose.Child
import androidx.compose.ui.layout.layout import com.arkivanov.decompose.router.stack.ChildStack
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.FaultyDecomposeApi
import com.arkivanov.decompose.extensions.compose.stack.animation.*
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.utils.toPx
import com.tangem.features.feed.components.FeedEntryChildFactory 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 typealias FeedEntryActiveChild =
internal fun topBarFeedEntryStackAnimation(): StackAnimation< Child.Created<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>
FeedEntryChildFactory.Child,
ComposableModularBottomSheetContentComponent, internal typealias FeedEntryChildStack =
> = ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>
stackAnimation { to, from, _ ->
val isSearchToTokenList = internal fun topBarFeedEntryAnimatedContentTransitionSpec(
(to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true stackState: State<FeedEntryChildStack>,
val isFromSearchTokenList = ): AnimatedContentTransitionScope<FeedEntryActiveChild>.() -> ContentTransform =
(from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true { feedEntryAnimatedContentTransform(stackState.value) }
if (isSearchToTokenList || isFromSearchTokenList) {
fade() internal fun contentFeedEntryAnimatedContentTransitionSpec(
} else { stackState: State<FeedEntryChildStack>,
slide() ): AnimatedContentTransitionScope<FeedEntryActiveChild>.() -> ContentTransform =
} { feedEntryAnimatedContentTransform(stackState.value) }
private fun AnimatedContentTransitionScope<FeedEntryActiveChild>.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) private fun AnimatedContentTransitionScope<FeedEntryActiveChild>.feedEntrySlideTransform(
internal fun contentFeedEntryStackAnimation(): StackAnimation< stack: FeedEntryChildStack,
FeedEntryChildFactory.Child, ): ContentTransform {
ComposableModularBottomSheetContentComponent, val isPushing = stack.backStack.lastOrNull()?.configuration == initialState.configuration
> = return if (isPushing) {
stackAnimation { to, from, _ -> slideInHorizontally(
val isSearchToTokenList = animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS),
(to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true initialOffsetX = { it },
val isFromSearchTokenList = ) togetherWith slideOutHorizontally(
(from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS),
if (isSearchToTokenList || isFromSearchTokenList) { targetOffsetX = { -it },
fadeAndSlideInt() )
} else { } else {
slide() slideInHorizontally(
} animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS),
} initialOffsetX = { -it },
) togetherWith slideOutHorizontally(
private fun fadeAndSlideInt(): StackAnimator = animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS),
stackAnimator(animationSpec = tween(DELAY_FOR_TRANSITION)) { factor, _, content -> targetOffsetX = { it },
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 Modifier.offsetY(pixels: Int): Modifier = layout { measurable, constraints -> private const val FEED_ENTRY_FADE_DURATION_MS = 300
val placeable = measurable.measure(constraints) private const val FEED_ENTRY_SLIDE_DURATION_MS = 300
layout(placeable.width, placeable.height) {
placeable.placeRelative(x = 0, y = pixels)
}
}