Updated on 2026-08-14
This commit is contained in:
parent
943baed9b8
commit
5e12c2dc29
14 changed files with 238 additions and 11 deletions
|
|
@ -132,8 +132,8 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
|||
innerRouter.push(FeedEntryChildFactory.Child.Earn)
|
||||
}
|
||||
|
||||
override fun openSearch() {
|
||||
innerRouter.push(FeedEntryChildFactory.Child.Search)
|
||||
override fun openSearch(source: String) {
|
||||
innerRouter.push(FeedEntryChildFactory.Child.Search(source))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ internal class FeedEntryChildFactory @Inject constructor(
|
|||
|
||||
@Serializable
|
||||
@Immutable
|
||||
data object Search : Child
|
||||
data class Search(val source: String) : Child
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
|
|
@ -144,7 +144,7 @@ internal class FeedEntryChildFactory @Inject constructor(
|
|||
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
|
||||
)
|
||||
}
|
||||
Child.Search -> DefaultSearchComponent(
|
||||
is Child.Search -> DefaultSearchComponent(
|
||||
appComponentContext = appComponentContext,
|
||||
params = DefaultSearchComponent.Params(
|
||||
onBackClick = onBackClicked,
|
||||
|
|
@ -155,6 +155,7 @@ internal class FeedEntryChildFactory @Inject constructor(
|
|||
source = AnalyticsParam.ScreensSources.Market.value,
|
||||
)
|
||||
},
|
||||
sourceParams = child.source,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,6 @@ internal class DefaultEarnComponent(
|
|||
|
||||
data class Params(
|
||||
val onBackClick: () -> Unit,
|
||||
val onSearchClicked: () -> Unit,
|
||||
val onSearchClicked: (source: String) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -134,7 +134,7 @@ internal class DefaultMarketsTokenListComponent(
|
|||
|
||||
data class ClickIntents(
|
||||
val onBackClicked: () -> Unit,
|
||||
val onSearchClicked: () -> Unit,
|
||||
val onSearchClicked: (source: String) -> Unit,
|
||||
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -120,5 +120,6 @@ internal class DefaultSearchComponent(
|
|||
data class Params(
|
||||
val onBackClick: () -> Unit,
|
||||
val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
|
||||
val sourceParams: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import com.arkivanov.decompose.router.slot.dismiss
|
|||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
|
|
@ -325,7 +326,7 @@ internal class EarnModel @Inject constructor(
|
|||
onNetworkFilterClick = ::onNetworkFilterClick,
|
||||
onTypeFilterClick = ::onTypeFilterClick,
|
||||
onScroll = ::onMostlyUsedScrolled,
|
||||
onSearchBarClicked = params.onSearchClicked,
|
||||
onSearchBarClicked = { params.onSearchClicked(AnalyticsParam.ScreensSources.Earn.value) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ internal class FeedComponentModel @Inject constructor(
|
|||
onBarClick = {
|
||||
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
params.feedClickIntents.openSearch()
|
||||
params.feedClickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value)
|
||||
} else {
|
||||
params.feedClickIntents.onMarketOpenClick(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,5 @@ internal interface FeedModelClickIntents {
|
|||
|
||||
fun onOpenEarnPage()
|
||||
|
||||
fun openSearch()
|
||||
fun openSearch(source: String)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
|
|||
import arrow.core.getOrElse
|
||||
import com.tangem.common.ui.markets.models.MarketsListItemUM
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
|
|
@ -70,7 +71,7 @@ internal class MarketsListModel @Inject constructor(
|
|||
preselectedInterval = Provider { modelParams.params.preselectedInterval },
|
||||
onBackClick = modelParams.clickIntents.onBackClicked,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
onSearchBarClick = modelParams.clickIntents.onSearchClicked,
|
||||
onSearchBarClick = { modelParams.clickIntents.onSearchClicked(AnalyticsParam.ScreensSources.Market.value) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,21 @@ internal class MarketsListBatchFlowManager(
|
|||
initialValue = false,
|
||||
)
|
||||
|
||||
val initialLoadingError: Flow<Throwable> = batchFlow.state
|
||||
.map { it.status }
|
||||
.distinctUntilChanged()
|
||||
.filterIsInstance<PaginationStatus.InitialLoadingError>()
|
||||
.map { it.throwable }
|
||||
|
||||
val totalCount: StateFlow<Int?> = batchFlow.state
|
||||
.map { it.totalCount }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
val isSearchNotFoundState = batchFlow.state
|
||||
.map { batchListState ->
|
||||
currentSearchText().isNullOrEmpty().not() &&
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ 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.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
|
|
@ -28,6 +29,7 @@ import com.tangem.features.feed.components.search.SearchBottomSheetRoute
|
|||
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.analytics.SearchAnalyticsHelper
|
||||
import com.tangem.features.feed.model.search.converter.*
|
||||
import com.tangem.features.feed.model.search.state.SearchStateController
|
||||
import com.tangem.features.feed.model.search.state.transformers.*
|
||||
|
|
@ -47,6 +49,7 @@ import javax.inject.Inject
|
|||
|
||||
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
|
||||
private const val MARKET_SEARCH_DEBOUNCE_MS = 500L
|
||||
private const val RESULTS_SHOWN_DEBOUNCE_MS = 1000L
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@ModelScoped
|
||||
|
|
@ -63,6 +66,7 @@ internal class SearchModel @Inject constructor(
|
|||
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
|
||||
private val appRouter: AppRouter,
|
||||
private val stateController: SearchStateController,
|
||||
private val searchAnalyticsHelper: SearchAnalyticsHelper,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<DefaultSearchComponent.Params>()
|
||||
|
|
@ -71,7 +75,6 @@ internal class SearchModel @Inject constructor(
|
|||
private val searchResultsJob = JobHolder()
|
||||
private val marketSearchDebounceJob = JobHolder()
|
||||
private var shouldShowAllTokensIncludingUnder100k = false
|
||||
|
||||
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
|
||||
maybeAppCurrency.getOrElse { AppCurrency.Default }
|
||||
}.stateIn(
|
||||
|
|
@ -122,7 +125,10 @@ internal class SearchModel @Inject constructor(
|
|||
subscribeToMarketUiItems()
|
||||
subscribeToQuotesPolling()
|
||||
subscribeToAppCurrencyChanges()
|
||||
subscribeToMarketLoadingErrors()
|
||||
subscribeToResultsShown()
|
||||
loadHistory()
|
||||
searchAnalyticsHelper.sendSearchScreenOpened(params.sourceParams)
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
|
|
@ -138,11 +144,14 @@ internal class SearchModel @Inject constructor(
|
|||
fun clearSearchHistory() {
|
||||
modelScope.launch(dispatchers.default) {
|
||||
clearSearchHistoryUseCase()
|
||||
searchAnalyticsHelper.sendClearButtonClicked()
|
||||
}
|
||||
}
|
||||
|
||||
fun onTextHintClick(text: String) {
|
||||
stateController.update(UpdateSearchBarQueryTransformer(text))
|
||||
searchAnalyticsHelper.sendHintClicked(text)
|
||||
searchAnalyticsHelper.sendSearchStarted()
|
||||
}
|
||||
|
||||
fun onResultMarketTokenClick(item: MarketsListItemUM) {
|
||||
|
|
@ -155,6 +164,7 @@ internal class SearchModel @Inject constructor(
|
|||
)
|
||||
saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input))
|
||||
saveSearchQueryUseCase(stateController.value.searchBar.query)
|
||||
searchAnalyticsHelper.sendMarketItemClicked(item.currencySymbol)
|
||||
withContext(dispatchers.mainImmediate) {
|
||||
searchMarketsListManager.getTokenById(item.id)?.let { found ->
|
||||
params.onMarketTokenClick(found.toSerializableParam(), appCurrency)
|
||||
|
|
@ -176,6 +186,7 @@ internal class SearchModel @Inject constructor(
|
|||
),
|
||||
imageUrl = item.iconUrl,
|
||||
)
|
||||
searchAnalyticsHelper.sendRecentItemClicked(item.currencySymbol)
|
||||
params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value)
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +206,7 @@ internal class SearchModel @Inject constructor(
|
|||
|
||||
private fun onQueryChange(query: String) {
|
||||
stateController.update(UpdateSearchBarQueryTransformer(query))
|
||||
searchAnalyticsHelper.sendSearchStarted()
|
||||
}
|
||||
|
||||
private fun onClearClick() {
|
||||
|
|
@ -202,6 +214,7 @@ internal class SearchModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) {
|
||||
searchAnalyticsHelper.sendPortfolioItemClicked(entry.currencyStatus.currency.symbol)
|
||||
appRouter.push(
|
||||
AppRoute.CurrencyDetails(
|
||||
userWalletId = entry.userWalletId,
|
||||
|
|
@ -211,6 +224,7 @@ internal class SearchModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) {
|
||||
searchAnalyticsHelper.sendGroupClicked(grouped.tokenSymbol)
|
||||
bottomSheetNavigation.activate(
|
||||
SearchBottomSheetRoute.TokenSelector(
|
||||
entries = grouped.entries,
|
||||
|
|
@ -291,6 +305,50 @@ internal class SearchModel @Inject constructor(
|
|||
}.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun subscribeToMarketLoadingErrors() {
|
||||
searchMarketsListManager.initialLoadingError
|
||||
.onEach { throwable ->
|
||||
val (code, message) = when (throwable) {
|
||||
is ApiResponseError.HttpException ->
|
||||
throwable.code.numericCode to throwable.message.orEmpty()
|
||||
else -> null to throwable.message.orEmpty()
|
||||
}
|
||||
searchAnalyticsHelper.sendErrorMarketsData(code, message)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
private fun subscribeToResultsShown() {
|
||||
stateController.uiState
|
||||
.map { it.searchBar.query.trim() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { query ->
|
||||
if (query.isEmpty()) {
|
||||
emptyFlow()
|
||||
} else {
|
||||
combine(
|
||||
searchMarketsListManager.totalCount.filterNotNull(),
|
||||
stateController.uiState.map { state ->
|
||||
(state.content as? SearchContentUM.Results)?.userAssets?.size ?: 0
|
||||
}.distinctUntilChanged(),
|
||||
) { marketCount, userAssetsCount ->
|
||||
marketCount to userAssetsCount
|
||||
}
|
||||
.debounce(RESULTS_SHOWN_DEBOUNCE_MS)
|
||||
.take(1)
|
||||
}
|
||||
}
|
||||
.onEach { (marketCount, userAssetsCount) ->
|
||||
searchAnalyticsHelper.sendResultShown(
|
||||
totalResultsCount = marketCount + userAssetsCount,
|
||||
marketsResultsCount = marketCount,
|
||||
userTokensResultsCount = userAssetsCount,
|
||||
)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun subscribeToMarketUiItems() {
|
||||
combine(
|
||||
flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(),
|
||||
|
|
@ -312,6 +370,7 @@ internal class SearchModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.onEach { snapshot ->
|
||||
stateController.update(ApplySearchMarketBatchTransformer(snapshot))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
package com.tangem.features.feed.model.search.analytics
|
||||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
|
||||
import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR
|
||||
|
||||
internal sealed class SearchAnalyticsEvent(
|
||||
event: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent(category = "Search", event = event, params = params) {
|
||||
|
||||
data class SearchScreenOpened(
|
||||
private val screensSource: String,
|
||||
) : SearchAnalyticsEvent(
|
||||
event = "Search Screen Opened",
|
||||
params = mapOf(SOURCE to screensSource),
|
||||
)
|
||||
|
||||
class SearchStarted : SearchAnalyticsEvent(event = "Search Started")
|
||||
|
||||
data class ResultsShown(
|
||||
private val totalResultsCount: Int,
|
||||
private val marketsResultsCount: Int,
|
||||
private val userTokensResultsCount: Int,
|
||||
) : SearchAnalyticsEvent(
|
||||
event = "Results Shown",
|
||||
params = mapOf(
|
||||
"Total Results" to totalResultsCount.toString(),
|
||||
"User Tokens Count" to userTokensResultsCount.toString(),
|
||||
"Market Tokens Count" to marketsResultsCount.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
data class ErrorMarketsData(
|
||||
private val code: Int?,
|
||||
private val message: String,
|
||||
) : SearchAnalyticsEvent(
|
||||
event = "Error - Markets Data",
|
||||
params = mapOf(
|
||||
ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(),
|
||||
ERROR_MESSAGE to message,
|
||||
),
|
||||
)
|
||||
|
||||
data class PortfolioItemClicked(
|
||||
private val tokenSymbol: String,
|
||||
) : SearchAnalyticsEvent(
|
||||
event = "Portfolio Item Clicked",
|
||||
params = mapOf(TOKEN_PARAM to tokenSymbol),
|
||||
)
|
||||
|
||||
data class HintClicked(
|
||||
private val hint: String,
|
||||
) : SearchAnalyticsEvent(
|
||||
event = "Hint Clicked",
|
||||
params = mapOf("Text" to hint),
|
||||
)
|
||||
|
||||
data class RecentItemClicked(
|
||||
private val tokenSymbol: String,
|
||||
) : SearchAnalyticsEvent(
|
||||
event = "Recent Item Clicked",
|
||||
params = mapOf(TOKEN_PARAM to tokenSymbol),
|
||||
)
|
||||
|
||||
data class MarketItemClicked(
|
||||
private val tokenSymbol: String,
|
||||
) : SearchAnalyticsEvent(
|
||||
event = "Market Item Clicked",
|
||||
params = mapOf(TOKEN_PARAM to tokenSymbol),
|
||||
)
|
||||
|
||||
data class GroupClicked(
|
||||
private val tokenSymbol: String,
|
||||
) : SearchAnalyticsEvent(
|
||||
event = "Group Clicked",
|
||||
params = mapOf(TOKEN_PARAM to tokenSymbol),
|
||||
)
|
||||
|
||||
class ButtonClearHistoryClick : SearchAnalyticsEvent(event = "Button - Clear History")
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.tangem.features.feed.model.search.analytics
|
||||
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import javax.inject.Inject
|
||||
|
||||
class SearchAnalyticsHelper @Inject constructor(
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
) {
|
||||
|
||||
private var isSearchStartedWasSent = false
|
||||
|
||||
fun sendSearchScreenOpened(source: String) {
|
||||
analyticsEventHandler.send(SearchAnalyticsEvent.SearchScreenOpened(source))
|
||||
}
|
||||
|
||||
fun sendMarketItemClicked(tokenSymbol: String) {
|
||||
analyticsEventHandler.send(SearchAnalyticsEvent.MarketItemClicked(tokenSymbol))
|
||||
}
|
||||
|
||||
fun sendClearButtonClicked() {
|
||||
analyticsEventHandler.send(SearchAnalyticsEvent.ButtonClearHistoryClick())
|
||||
}
|
||||
|
||||
fun sendHintClicked(text: String) {
|
||||
analyticsEventHandler.send(SearchAnalyticsEvent.HintClicked(text))
|
||||
}
|
||||
|
||||
fun sendRecentItemClicked(tokenSymbol: String) {
|
||||
analyticsEventHandler.send(SearchAnalyticsEvent.RecentItemClicked(tokenSymbol))
|
||||
}
|
||||
|
||||
fun sendSearchStarted() {
|
||||
if (isSearchStartedWasSent) return
|
||||
analyticsEventHandler.send(SearchAnalyticsEvent.SearchStarted())
|
||||
isSearchStartedWasSent = true
|
||||
}
|
||||
|
||||
fun sendPortfolioItemClicked(tokenSymbol: String) {
|
||||
analyticsEventHandler.send(SearchAnalyticsEvent.PortfolioItemClicked(tokenSymbol))
|
||||
}
|
||||
|
||||
fun sendGroupClicked(tokenSymbol: String) {
|
||||
analyticsEventHandler.send(SearchAnalyticsEvent.GroupClicked(tokenSymbol))
|
||||
}
|
||||
|
||||
fun sendResultShown(totalResultsCount: Int, marketsResultsCount: Int, userTokensResultsCount: Int) {
|
||||
analyticsEventHandler.send(
|
||||
SearchAnalyticsEvent.ResultsShown(
|
||||
totalResultsCount = totalResultsCount,
|
||||
marketsResultsCount = marketsResultsCount,
|
||||
userTokensResultsCount = userTokensResultsCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendErrorMarketsData(code: Int?, message: String) {
|
||||
analyticsEventHandler.send(
|
||||
SearchAnalyticsEvent.ErrorMarketsData(
|
||||
code = code,
|
||||
message = message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue