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,
|
innerTextField = innerTextField,
|
||||||
focusManager = focusManager,
|
focusManager = focusManager,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
color = TangemTheme.colors2.field.backgroundDefault,
|
color = TangemTheme.colors2.button.backgroundSecondary,
|
||||||
keyboardController = keyboardController,
|
keyboardController = keyboardController,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -288,10 +288,9 @@ private fun CancelButton(
|
||||||
if (state.query.isNotEmpty()) {
|
if (state.query.isNotEmpty()) {
|
||||||
state.onQueryChange("")
|
state.onQueryChange("")
|
||||||
}
|
}
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
state.onActiveChange(false)
|
|
||||||
state.onClearClick()
|
state.onClearClick()
|
||||||
|
focusManager.clearFocus()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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.models)
|
||||||
implementation(projects.domain.yieldSupply)
|
implementation(projects.domain.yieldSupply)
|
||||||
implementation(projects.domain.earn)
|
implementation(projects.domain.earn)
|
||||||
|
implementation(projects.domain.search)
|
||||||
|
|
||||||
/* Compose */
|
/* Compose */
|
||||||
implementation(deps.compose.coil)
|
implementation(deps.compose.coil)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||||
import com.tangem.domain.markets.TokenMarketParams
|
import com.tangem.domain.markets.TokenMarketParams
|
||||||
import com.tangem.domain.news.model.NewsListConfig
|
import com.tangem.domain.news.model.NewsListConfig
|
||||||
import com.tangem.features.feed.components.earn.DefaultEarnComponent
|
|
||||||
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
|
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
|
||||||
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
|
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
|
||||||
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
|
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
|
||||||
|
|
@ -78,7 +77,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
||||||
screenSource = AnalyticsParam.ScreensSources.Token,
|
screenSource = AnalyticsParam.ScreensSources.Token,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onMarketOpenClick = { onMarketOpenClick(null) },
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -135,14 +133,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenEarnPage() {
|
override fun onOpenEarnPage() {
|
||||||
innerRouter.push(
|
innerRouter.push(FeedEntryChildFactory.Child.Earn)
|
||||||
FeedEntryChildFactory.Child.Earn(
|
}
|
||||||
params = DefaultEarnComponent.Params(
|
|
||||||
onBackClick = { onChildBack() },
|
override fun openSearch() {
|
||||||
onMarketOpenClick = { onMarketOpenClick(null) },
|
innerRouter.push(FeedEntryChildFactory.Child.Search)
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,7 +239,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
||||||
paginationConfig = null,
|
paginationConfig = null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onMarketOpenClick = { clickIntents.onMarketOpenClick(null) },
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
|
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,18 @@ import com.tangem.core.decompose.context.AppComponentContext
|
||||||
import com.tangem.core.decompose.navigation.Route
|
import com.tangem.core.decompose.navigation.Route
|
||||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||||
import com.tangem.features.feed.components.earn.DefaultEarnComponent
|
import com.tangem.features.feed.components.earn.DefaultEarnComponent
|
||||||
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
|
|
||||||
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
|
|
||||||
import com.tangem.features.feed.components.feed.DefaultFeedComponent
|
import com.tangem.features.feed.components.feed.DefaultFeedComponent
|
||||||
|
import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams
|
||||||
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
|
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
|
||||||
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
|
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
|
||||||
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
|
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
|
||||||
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
|
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
|
||||||
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
|
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
|
||||||
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
|
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
|
||||||
|
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent.Params
|
||||||
|
import com.tangem.features.feed.components.search.DefaultSearchComponent
|
||||||
|
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
|
||||||
|
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -53,7 +56,11 @@ internal class FeedEntryChildFactory @Inject constructor(
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Earn(val params: DefaultEarnComponent.Params) : Child
|
data object Earn : Child
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@Immutable
|
||||||
|
data object Search : Child
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createChild(
|
fun createChild(
|
||||||
|
|
@ -86,7 +93,7 @@ internal class FeedEntryChildFactory @Inject constructor(
|
||||||
Child.NewsList -> {
|
Child.NewsList -> {
|
||||||
DefaultNewsListComponent(
|
DefaultNewsListComponent(
|
||||||
appComponentContext = appComponentContext,
|
appComponentContext = appComponentContext,
|
||||||
params = DefaultNewsListComponent.Params(
|
params = Params(
|
||||||
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
|
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
|
||||||
feedEntryClickIntents.onArticleClick(
|
feedEntryClickIntents.onArticleClick(
|
||||||
articleId = currentArticle,
|
articleId = currentArticle,
|
||||||
|
|
@ -102,7 +109,7 @@ internal class FeedEntryChildFactory @Inject constructor(
|
||||||
Child.Feed -> {
|
Child.Feed -> {
|
||||||
DefaultFeedComponent(
|
DefaultFeedComponent(
|
||||||
appComponentContext = appComponentContext,
|
appComponentContext = appComponentContext,
|
||||||
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
|
params = FeedParams(feedClickIntents = feedEntryClickIntents),
|
||||||
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
|
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
|
||||||
promoBannersBlockComponentFactory = promoBannersBlockComponentFactory,
|
promoBannersBlockComponentFactory = promoBannersBlockComponentFactory,
|
||||||
newPromoBannersFeatureToggles = newPromoBannersFeatureToggles,
|
newPromoBannersFeatureToggles = newPromoBannersFeatureToggles,
|
||||||
|
|
@ -111,10 +118,19 @@ internal class FeedEntryChildFactory @Inject constructor(
|
||||||
is Child.Earn -> {
|
is Child.Earn -> {
|
||||||
DefaultEarnComponent(
|
DefaultEarnComponent(
|
||||||
appComponentContext = appComponentContext,
|
appComponentContext = appComponentContext,
|
||||||
params = child.params,
|
params = DefaultEarnComponent.Params(
|
||||||
|
onBackClick = onBackClicked,
|
||||||
|
onSearchClicked = feedEntryClickIntents::openSearch,
|
||||||
|
),
|
||||||
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
|
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Child.Search -> DefaultSearchComponent(
|
||||||
|
appComponentContext = appComponentContext,
|
||||||
|
params = DefaultSearchComponent.Params(
|
||||||
|
onBackClick = onBackClicked,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +35,6 @@ import com.tangem.features.feed.components.market.details.portfolio.add.AddToPor
|
||||||
import com.tangem.features.feed.model.earn.EarnModel
|
import com.tangem.features.feed.model.earn.EarnModel
|
||||||
import com.tangem.features.feed.ui.components.FeedSearchBar
|
import com.tangem.features.feed.ui.components.FeedSearchBar
|
||||||
import com.tangem.features.feed.ui.earn.EarnContent
|
import com.tangem.features.feed.ui.earn.EarnContent
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
internal class DefaultEarnComponent(
|
internal class DefaultEarnComponent(
|
||||||
appComponentContext: AppComponentContext,
|
appComponentContext: AppComponentContext,
|
||||||
|
|
@ -129,9 +128,8 @@ internal class DefaultEarnComponent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Params(
|
data class Params(
|
||||||
val onBackClick: () -> Unit,
|
val onBackClick: () -> Unit,
|
||||||
val onMarketOpenClick: () -> Unit,
|
val onSearchClicked: () -> Unit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -10,7 +10,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawBehind
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
|
|
@ -23,6 +22,8 @@ import com.tangem.core.decompose.model.getOrCreateModel
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||||
|
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||||
|
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
||||||
import com.tangem.core.ui.extensions.clickableSingle
|
import com.tangem.core.ui.extensions.clickableSingle
|
||||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||||
|
|
@ -34,7 +35,6 @@ import com.tangem.features.feed.components.market.details.portfolio.api.MarketsP
|
||||||
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
|
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
|
||||||
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
|
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
|
||||||
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
|
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
|
||||||
import com.tangem.features.feed.ui.components.FeedSearchBar
|
|
||||||
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent
|
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent
|
||||||
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar
|
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
@ -100,10 +100,7 @@ internal class DefaultMarketsTokenDetailsComponent(
|
||||||
val state by model.state.collectAsStateWithLifecycle()
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
val background = LocalMainBottomSheetColor.current.value
|
val background = LocalMainBottomSheetColor.current.value
|
||||||
if (LocalRedesignEnabled.current) {
|
if (LocalRedesignEnabled.current) {
|
||||||
FeedSearchBar(
|
TangemTopBar(
|
||||||
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
|
|
||||||
feedListSearchBar = state.feedListSearchBar,
|
|
||||||
modifier = Modifier.drawBehind { drawRect(background) },
|
|
||||||
startContent = {
|
startContent = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
|
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
|
||||||
|
|
@ -140,6 +137,7 @@ internal class DefaultMarketsTokenDetailsComponent(
|
||||||
.padding(TangemTheme.dimens2.x2_5),
|
.padding(TangemTheme.dimens2.x2_5),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
type = TangemTopBarType.BottomSheet,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MarketsTokenDetailsTopBar(
|
MarketsTokenDetailsTopBar(
|
||||||
|
|
@ -188,7 +186,6 @@ internal class DefaultMarketsTokenDetailsComponent(
|
||||||
val analyticsParams: AnalyticsParams?,
|
val analyticsParams: AnalyticsParams?,
|
||||||
val onBackClicked: () -> Unit,
|
val onBackClicked: () -> Unit,
|
||||||
val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit,
|
val onArticleClick: (articleId: Int, preselectedArticlesId: List<Int>) -> Unit,
|
||||||
val onMarketOpenClick: () -> Unit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -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.market.list.MarketsListModel
|
||||||
import com.tangem.features.feed.model.news.details.NewsDetailsModel
|
import com.tangem.features.feed.model.news.details.NewsDetailsModel
|
||||||
import com.tangem.features.feed.model.news.list.NewsListModel
|
import com.tangem.features.feed.model.news.list.NewsListModel
|
||||||
|
import com.tangem.features.feed.model.search.SearchModel
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -65,4 +66,9 @@ internal interface ModelModule {
|
||||||
@IntoMap
|
@IntoMap
|
||||||
@ClassKey(EarnTypeFilterModel::class)
|
@ClassKey(EarnTypeFilterModel::class)
|
||||||
fun provideEarnTypeFilterModel(model: EarnTypeFilterModel): Model
|
fun provideEarnTypeFilterModel(model: EarnTypeFilterModel): Model
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@ClassKey(SearchModel::class)
|
||||||
|
fun provideSearchModel(model: SearchModel): Model
|
||||||
}
|
}
|
||||||
|
|
@ -325,7 +325,7 @@ internal class EarnModel @Inject constructor(
|
||||||
onNetworkFilterClick = ::onNetworkFilterClick,
|
onNetworkFilterClick = ::onNetworkFilterClick,
|
||||||
onTypeFilterClick = ::onTypeFilterClick,
|
onTypeFilterClick = ::onTypeFilterClick,
|
||||||
onScroll = ::onMostlyUsedScrolled,
|
onScroll = ::onMostlyUsedScrolled,
|
||||||
onSearchBarClicked = params.onMarketOpenClick,
|
onSearchBarClicked = params.onSearchClicked,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam
|
||||||
import com.tangem.core.decompose.di.ModelScoped
|
import com.tangem.core.decompose.di.ModelScoped
|
||||||
import com.tangem.core.decompose.model.Model
|
import com.tangem.core.decompose.model.Model
|
||||||
import com.tangem.core.decompose.model.ParamsContainer
|
import com.tangem.core.decompose.model.ParamsContainer
|
||||||
|
import com.tangem.core.ui.DesignFeatureToggles
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
import com.tangem.core.ui.extensions.resourceReference
|
||||||
import com.tangem.core.ui.utils.DateTimeFormatters
|
import com.tangem.core.ui.utils.DateTimeFormatters
|
||||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||||
|
|
@ -66,6 +67,7 @@ internal class FeedComponentModel @Inject constructor(
|
||||||
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
|
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
|
||||||
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
|
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
|
||||||
private val appRouter: AppRouter,
|
private val appRouter: AppRouter,
|
||||||
|
private val designFeatureToggles: DesignFeatureToggles,
|
||||||
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
|
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
|
||||||
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
||||||
paramsContainer: ParamsContainer,
|
paramsContainer: ParamsContainer,
|
||||||
|
|
@ -233,7 +235,7 @@ internal class FeedComponentModel @Inject constructor(
|
||||||
currentDate = getCurrentDate(),
|
currentDate = getCurrentDate(),
|
||||||
feedListSearchBar = FeedListSearchBar(
|
feedListSearchBar = FeedListSearchBar(
|
||||||
placeholderText = resourceReference(
|
placeholderText = resourceReference(
|
||||||
id = if (feedFeatureToggle.isEarnBlockEnabled) {
|
id = if (designFeatureToggles.isRedesignEnabled) {
|
||||||
R.string.markets_search_title_placeholder
|
R.string.markets_search_title_placeholder
|
||||||
} else {
|
} else {
|
||||||
R.string.markets_search_header_title
|
R.string.markets_search_header_title
|
||||||
|
|
@ -241,7 +243,11 @@ internal class FeedComponentModel @Inject constructor(
|
||||||
),
|
),
|
||||||
onBarClick = {
|
onBarClick = {
|
||||||
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
|
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
|
||||||
params.feedClickIntents.onMarketOpenClick(null)
|
if (designFeatureToggles.isRedesignEnabled) {
|
||||||
|
params.feedClickIntents.openSearch()
|
||||||
|
} else {
|
||||||
|
params.feedClickIntents.onMarketOpenClick(null)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
feedListCallbacks = FeedListCallbacks(
|
feedListCallbacks = FeedListCallbacks(
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,6 @@ internal interface FeedModelClickIntents {
|
||||||
fun onOpenAllNews()
|
fun onOpenAllNews()
|
||||||
|
|
||||||
fun onOpenEarnPage()
|
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.formatter.*
|
||||||
import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater
|
import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater
|
||||||
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
|
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
|
||||||
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
|
|
||||||
import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent
|
import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent
|
||||||
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
|
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
|
||||||
import com.tangem.lib.crypto.BlockchainUtils
|
import com.tangem.lib.crypto.BlockchainUtils
|
||||||
|
|
@ -260,10 +259,6 @@ internal class MarketsTokenDetailsModel @Inject constructor(
|
||||||
onScroll = {},
|
onScroll = {},
|
||||||
),
|
),
|
||||||
onShareClick = ::onShareClick,
|
onShareClick = ::onShareClick,
|
||||||
feedListSearchBar = FeedListSearchBar(
|
|
||||||
onBarClick = { params.onMarketOpenClick() },
|
|
||||||
placeholderText = resourceReference(id = R.string.markets_search_title_placeholder),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,11 @@ internal class MarketsListBatchFlowManager(
|
||||||
private val resultBatches = MutableStateFlow(ResultBatches())
|
private val resultBatches = MutableStateFlow(ResultBatches())
|
||||||
private val uiBatches = stateManager.state.map { it.uiBatches }
|
private val uiBatches = stateManager.state.map { it.uiBatches }
|
||||||
|
|
||||||
|
val rawItems: Flow<List<TokenMarket>>
|
||||||
|
get() = batchFlow.state
|
||||||
|
.map { state -> state.data.flatMap { batch -> batch.data } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
|
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
|
||||||
get() = uiBatches
|
get() = uiBatches
|
||||||
.map { batches ->
|
.map { batches ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.tangem.features.feed.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
|
@ -11,16 +12,17 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import com.arkivanov.decompose.extensions.compose.stack.Children
|
import com.arkivanov.decompose.ExperimentalDecomposeApi
|
||||||
import com.arkivanov.decompose.router.stack.ChildStack
|
import com.arkivanov.decompose.router.stack.ChildStack
|
||||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||||
import com.tangem.features.feed.components.FeedEntryChildFactory
|
import com.tangem.features.feed.components.FeedEntryChildFactory
|
||||||
import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation
|
import com.tangem.features.feed.ui.utils.contentFeedEntryAnimatedContentTransitionSpec
|
||||||
import com.tangem.features.feed.ui.utils.topBarFeedEntryStackAnimation
|
import com.tangem.features.feed.ui.utils.topBarFeedEntryAnimatedContentTransitionSpec
|
||||||
|
|
||||||
|
@OptIn(ExperimentalDecomposeApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun EntryContent(
|
internal fun EntryContent(
|
||||||
bottomSheetState: State<BottomSheetState>,
|
bottomSheetState: State<BottomSheetState>,
|
||||||
|
|
@ -30,15 +32,15 @@ internal fun EntryContent(
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val background = LocalMainBottomSheetColor.current.value
|
val background = LocalMainBottomSheetColor.current.value
|
||||||
val animationContent = remember { contentFeedEntryStackAnimation() }
|
val animationContent = remember(stackState) { contentFeedEntryAnimatedContentTransitionSpec(stackState) }
|
||||||
val animationAppBar = remember { topBarFeedEntryStackAnimation() }
|
val animationAppBar = remember(stackState) { topBarFeedEntryAnimatedContentTransitionSpec(stackState) }
|
||||||
|
|
||||||
Surface(contentColor = background) {
|
Surface(contentColor = background) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = background,
|
containerColor = background,
|
||||||
contentWindowInsets = WindowInsetsZero,
|
contentWindowInsets = WindowInsetsZero,
|
||||||
topBar = {
|
topBar = {
|
||||||
Children(
|
AnimatedContent(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.then(
|
.then(
|
||||||
if (!isOpenedInBottomSheet) {
|
if (!isOpenedInBottomSheet) {
|
||||||
|
|
@ -54,18 +56,22 @@ internal fun EntryContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
stack = stackState.value,
|
targetState = stackState.value.active,
|
||||||
animation = animationAppBar,
|
transitionSpec = animationAppBar,
|
||||||
) { child ->
|
contentKey = { it.key },
|
||||||
child.instance.Title(bottomSheetState)
|
label = "FeedEntryAppBar",
|
||||||
|
) { state ->
|
||||||
|
state.instance.Title(bottomSheetState)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
content = { contentPadding ->
|
content = { contentPadding ->
|
||||||
Children(
|
AnimatedContent(
|
||||||
stack = stackState.value,
|
targetState = stackState.value.active,
|
||||||
animation = animationContent,
|
transitionSpec = animationContent,
|
||||||
) { child ->
|
contentKey = { it.key },
|
||||||
child.instance.Content(
|
label = "FeedEntryContent",
|
||||||
|
) { state ->
|
||||||
|
state.instance.Content(
|
||||||
modifier = Modifier.padding(contentPadding),
|
modifier = Modifier.padding(contentPadding),
|
||||||
bottomSheetState = bottomSheetState,
|
bottomSheetState = bottomSheetState,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ private fun FeedSearchBarV2(
|
||||||
end = if (endContent != null) TangemTheme.dimens2.x3 else 0.dp,
|
end = if (endContent != null) TangemTheme.dimens2.x3 else 0.dp,
|
||||||
)
|
)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(color = TangemTheme.colors2.field.backgroundDefault)
|
.background(color = TangemTheme.colors2.button.backgroundSecondary)
|
||||||
.conditional(condition = isSearchBarClickable) {
|
.conditional(condition = isSearchBarClickable) {
|
||||||
clickable(onClick = feedListSearchBar.onBarClick)
|
clickable(onClick = feedListSearchBar.onBarClick)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,10 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
|
|
||||||
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
|
|
||||||
import com.tangem.core.ui.R
|
import com.tangem.core.ui.R
|
||||||
import com.tangem.core.ui.components.SpacerH
|
import com.tangem.core.ui.components.SpacerH
|
||||||
import com.tangem.core.ui.components.SpacerW
|
import com.tangem.core.ui.components.SpacerW
|
||||||
|
|
@ -31,6 +30,8 @@ import com.tangem.core.ui.components.block.TangemBlockCardColors
|
||||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
|
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
|
||||||
|
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
|
||||||
import com.tangem.features.feed.ui.feed.state.*
|
import com.tangem.features.feed.ui.feed.state.*
|
||||||
|
|
||||||
internal const val FOURTH_ITEM_INDEX = 3
|
internal const val FOURTH_ITEM_INDEX = 3
|
||||||
|
|
@ -87,8 +88,16 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResourceSafe(R.string.common_news),
|
text = stringResourceSafe(R.string.common_news),
|
||||||
style = TangemTheme.typography.h3,
|
style = if (isRedesignEnabled) {
|
||||||
color = TangemTheme.colors.text.primary1,
|
TangemTheme.typography2.headingSemibold20
|
||||||
|
} else {
|
||||||
|
TangemTheme.typography.h3
|
||||||
|
},
|
||||||
|
color = if (isRedesignEnabled) {
|
||||||
|
TangemTheme.colors2.text.neutral.primary
|
||||||
|
} else {
|
||||||
|
TangemTheme.colors.text.primary1
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
SpacerW(4.dp)
|
SpacerW(4.dp)
|
||||||
|
|
@ -113,7 +122,11 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
|
||||||
append(stringResourceSafe(R.string.feed_tangem_ai))
|
append(stringResourceSafe(R.string.feed_tangem_ai))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style = TangemTheme.typography.subtitle1,
|
style = if (isRedesignEnabled) {
|
||||||
|
TangemTheme.typography2.bodyRegular16.copy(fontWeight = FontWeight.Medium)
|
||||||
|
} else {
|
||||||
|
TangemTheme.typography.subtitle1
|
||||||
|
},
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||||
import com.tangem.core.ui.event.consumedEvent
|
import com.tangem.core.ui.event.consumedEvent
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
|
||||||
import com.tangem.core.ui.extensions.stringReference
|
import com.tangem.core.ui.extensions.stringReference
|
||||||
import com.tangem.domain.markets.PriceChangeInterval
|
import com.tangem.domain.markets.PriceChangeInterval
|
||||||
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
|
|
||||||
import com.tangem.features.feed.ui.market.detailed.state.*
|
import com.tangem.features.feed.ui.market.detailed.state.*
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
|
@ -52,10 +50,6 @@ internal object MarketsTokenDetailsPreview {
|
||||||
onScroll = {},
|
onScroll = {},
|
||||||
),
|
),
|
||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
feedListSearchBar = FeedListSearchBar(
|
|
||||||
placeholderText = TextReference.Str("Search tokens & news"),
|
|
||||||
onBarClick = {},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val contentState = MarketsTokenDetailsUM(
|
val contentState = MarketsTokenDetailsUM(
|
||||||
|
|
@ -150,9 +144,5 @@ internal object MarketsTokenDetailsPreview {
|
||||||
onScroll = {},
|
onScroll = {},
|
||||||
),
|
),
|
||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
feedListSearchBar = FeedListSearchBar(
|
|
||||||
placeholderText = TextReference.Str("Search tokens & news"),
|
|
||||||
onBarClick = {},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -2,13 +2,12 @@ package com.tangem.features.feed.ui.market.detailed.state
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.tangem.common.ui.charts.state.MarketChartDataProducer
|
import com.tangem.common.ui.charts.state.MarketChartDataProducer
|
||||||
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
|
|
||||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||||
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
import com.tangem.core.ui.components.marketprice.PriceChangeType
|
||||||
import com.tangem.core.ui.event.StateEvent
|
import com.tangem.core.ui.event.StateEvent
|
||||||
import com.tangem.core.ui.extensions.TextReference
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.domain.markets.PriceChangeInterval
|
import com.tangem.domain.markets.PriceChangeInterval
|
||||||
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
|
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
|
@ -30,7 +29,6 @@ internal data class MarketsTokenDetailsUM(
|
||||||
val onShouldShowPriceSubtitleChange: (Boolean) -> Unit,
|
val onShouldShowPriceSubtitleChange: (Boolean) -> Unit,
|
||||||
val relatedNews: RelatedNews,
|
val relatedNews: RelatedNews,
|
||||||
val onShareClick: () -> Unit,
|
val onShareClick: () -> Unit,
|
||||||
val feedListSearchBar: FeedListSearchBar,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
data class ChartState(
|
data class ChartState(
|
||||||
|
|
|
||||||
|
|
@ -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
|
package com.tangem.features.feed.ui.utils
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.ui.draw.alpha
|
import com.arkivanov.decompose.Child
|
||||||
import androidx.compose.ui.layout.layout
|
import com.arkivanov.decompose.router.stack.ChildStack
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.arkivanov.decompose.FaultyDecomposeApi
|
|
||||||
import com.arkivanov.decompose.extensions.compose.stack.animation.*
|
|
||||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||||
import com.tangem.core.ui.utils.toPx
|
|
||||||
import com.tangem.features.feed.components.FeedEntryChildFactory
|
import com.tangem.features.feed.components.FeedEntryChildFactory
|
||||||
|
|
||||||
private const val DELAY_FOR_TRANSITION = 400
|
private fun FeedEntryChildFactory.Child?.usesFadeStackTransition(): Boolean = when (this) {
|
||||||
|
is FeedEntryChildFactory.Child.Search -> true
|
||||||
|
is FeedEntryChildFactory.Child.TokenList -> params.shouldAlwaysShowSearchBar
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(FaultyDecomposeApi::class)
|
internal typealias FeedEntryActiveChild =
|
||||||
internal fun topBarFeedEntryStackAnimation(): StackAnimation<
|
Child.Created<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>
|
||||||
FeedEntryChildFactory.Child,
|
|
||||||
ComposableModularBottomSheetContentComponent,
|
internal typealias FeedEntryChildStack =
|
||||||
> =
|
ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>
|
||||||
stackAnimation { to, from, _ ->
|
|
||||||
val isSearchToTokenList =
|
internal fun topBarFeedEntryAnimatedContentTransitionSpec(
|
||||||
(to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true
|
stackState: State<FeedEntryChildStack>,
|
||||||
val isFromSearchTokenList =
|
): AnimatedContentTransitionScope<FeedEntryActiveChild>.() -> ContentTransform =
|
||||||
(from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true
|
{ feedEntryAnimatedContentTransform(stackState.value) }
|
||||||
if (isSearchToTokenList || isFromSearchTokenList) {
|
|
||||||
fade()
|
internal fun contentFeedEntryAnimatedContentTransitionSpec(
|
||||||
} else {
|
stackState: State<FeedEntryChildStack>,
|
||||||
slide()
|
): AnimatedContentTransitionScope<FeedEntryActiveChild>.() -> ContentTransform =
|
||||||
}
|
{ feedEntryAnimatedContentTransform(stackState.value) }
|
||||||
|
|
||||||
|
private fun AnimatedContentTransitionScope<FeedEntryActiveChild>.feedEntryAnimatedContentTransform(
|
||||||
|
stack: FeedEntryChildStack,
|
||||||
|
): ContentTransform {
|
||||||
|
val shouldUseFade = initialState.configuration.usesFadeStackTransition() ||
|
||||||
|
targetState.configuration.usesFadeStackTransition()
|
||||||
|
return if (shouldUseFade) {
|
||||||
|
fadeIn(animationSpec = tween(FEED_ENTRY_FADE_DURATION_MS)) togetherWith
|
||||||
|
fadeOut(animationSpec = tween(FEED_ENTRY_FADE_DURATION_MS))
|
||||||
|
} else {
|
||||||
|
feedEntrySlideTransform(stack)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(FaultyDecomposeApi::class)
|
private fun AnimatedContentTransitionScope<FeedEntryActiveChild>.feedEntrySlideTransform(
|
||||||
internal fun contentFeedEntryStackAnimation(): StackAnimation<
|
stack: FeedEntryChildStack,
|
||||||
FeedEntryChildFactory.Child,
|
): ContentTransform {
|
||||||
ComposableModularBottomSheetContentComponent,
|
val isPushing = stack.backStack.lastOrNull()?.configuration == initialState.configuration
|
||||||
> =
|
return if (isPushing) {
|
||||||
stackAnimation { to, from, _ ->
|
slideInHorizontally(
|
||||||
val isSearchToTokenList =
|
animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS),
|
||||||
(to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true
|
initialOffsetX = { it },
|
||||||
val isFromSearchTokenList =
|
) togetherWith slideOutHorizontally(
|
||||||
(from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true
|
animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS),
|
||||||
if (isSearchToTokenList || isFromSearchTokenList) {
|
targetOffsetX = { -it },
|
||||||
fadeAndSlideInt()
|
)
|
||||||
} else {
|
} else {
|
||||||
slide()
|
slideInHorizontally(
|
||||||
}
|
animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS),
|
||||||
}
|
initialOffsetX = { -it },
|
||||||
|
) togetherWith slideOutHorizontally(
|
||||||
private fun fadeAndSlideInt(): StackAnimator =
|
animationSpec = tween(FEED_ENTRY_SLIDE_DURATION_MS),
|
||||||
stackAnimator(animationSpec = tween(DELAY_FOR_TRANSITION)) { factor, _, content ->
|
targetOffsetX = { it },
|
||||||
val alpha = 1f - kotlin.math.abs(factor)
|
|
||||||
val slidePx = 32.dp.toPx()
|
|
||||||
val yOffset = when {
|
|
||||||
factor > 0 -> (slidePx * factor).toInt()
|
|
||||||
factor < 0 -> (slidePx * factor * -1).toInt()
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
content(
|
|
||||||
Modifier
|
|
||||||
.alpha(alpha)
|
|
||||||
.offsetY(yOffset),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Modifier.offsetY(pixels: Int): Modifier = layout { measurable, constraints ->
|
private const val FEED_ENTRY_FADE_DURATION_MS = 300
|
||||||
val placeable = measurable.measure(constraints)
|
private const val FEED_ENTRY_SLIDE_DURATION_MS = 300
|
||||||
layout(placeable.width, placeable.height) {
|
|
||||||
placeable.placeRelative(x = 0, y = pixels)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue