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

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)
implementation(projects.domain.earn)
implementation(projects.domain.search)
/* Compose */
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.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(

View file

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

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

View file

@ -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<Int>) -> Unit,
val onMarketOpenClick: () -> Unit,
)
@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.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
}

View file

@ -325,7 +325,7 @@ internal class EarnModel @Inject constructor(
onNetworkFilterClick = ::onNetworkFilterClick,
onTypeFilterClick = ::onTypeFilterClick,
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.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(

View file

@ -29,4 +29,6 @@ internal interface FeedModelClickIntents {
fun onOpenAllNews()
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.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),
),
),
)

View file

@ -75,6 +75,11 @@ internal class MarketsListBatchFlowManager(
private val resultBatches = MutableStateFlow(ResultBatches())
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>>
get() = uiBatches
.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
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<BottomSheetState>,
@ -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,
)

View file

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

View file

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

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.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 = {},
),
)
}

View file

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

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
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<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>
internal typealias FeedEntryChildStack =
ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>
internal fun topBarFeedEntryAnimatedContentTransitionSpec(
stackState: State<FeedEntryChildStack>,
): AnimatedContentTransitionScope<FeedEntryActiveChild>.() -> ContentTransform =
{ feedEntryAnimatedContentTransform(stackState.value) }
internal fun contentFeedEntryAnimatedContentTransitionSpec(
stackState: State<FeedEntryChildStack>,
): 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)
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<FeedEntryActiveChild>.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)
}
}
private const val FEED_ENTRY_FADE_DURATION_MS = 300
private const val FEED_ENTRY_SLIDE_DURATION_MS = 300