Updated on 2026-08-14
This commit is contained in:
parent
e8b15413d2
commit
f26ad91361
33 changed files with 1693 additions and 131 deletions
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
9
core/ui/src/main/res/drawable/ic_return_24.xml
Normal file
9
core/ui/src/main/res/drawable/ic_return_24.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -325,7 +325,7 @@ internal class EarnModel @Inject constructor(
|
|||
onNetworkFilterClick = ::onNetworkFilterClick,
|
||||
onTypeFilterClick = ::onTypeFilterClick,
|
||||
onScroll = ::onMostlyUsedScrolled,
|
||||
onSearchBarClicked = params.onMarketOpenClick,
|
||||
onSearchBarClicked = params.onSearchClicked,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -29,4 +29,6 @@ internal interface FeedModelClickIntents {
|
|||
fun onOpenAllNews()
|
||||
|
||||
fun onOpenEarnPage()
|
||||
|
||||
fun openSearch()
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue