Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-20 11:40:26 +02:00
parent 943baed9b8
commit 5e12c2dc29
14 changed files with 238 additions and 11 deletions

View file

@ -99,6 +99,7 @@ sealed class AnalyticsParam {
data object NewsPage : ScreensSources("News Page") data object NewsPage : ScreensSources("News Page")
data object Portfolio : ScreensSources("Portfolio") data object Portfolio : ScreensSources("Portfolio")
data object Staking : ScreensSources("Staking") data object Staking : ScreensSources("Staking")
data object Earn : ScreensSources("Earn")
} }
sealed class TxSentFrom(val value: String) { sealed class TxSentFrom(val value: String) {

View file

@ -132,8 +132,8 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
innerRouter.push(FeedEntryChildFactory.Child.Earn) innerRouter.push(FeedEntryChildFactory.Child.Earn)
} }
override fun openSearch() { override fun openSearch(source: String) {
innerRouter.push(FeedEntryChildFactory.Child.Search) innerRouter.push(FeedEntryChildFactory.Child.Search(source))
} }
} }

View file

@ -65,7 +65,7 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable @Serializable
@Immutable @Immutable
data object Search : Child data class Search(val source: String) : Child
} }
@Suppress("LongMethod") @Suppress("LongMethod")
@ -144,7 +144,7 @@ internal class FeedEntryChildFactory @Inject constructor(
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
) )
} }
Child.Search -> DefaultSearchComponent( is Child.Search -> DefaultSearchComponent(
appComponentContext = appComponentContext, appComponentContext = appComponentContext,
params = DefaultSearchComponent.Params( params = DefaultSearchComponent.Params(
onBackClick = onBackClicked, onBackClick = onBackClicked,
@ -155,6 +155,7 @@ internal class FeedEntryChildFactory @Inject constructor(
source = AnalyticsParam.ScreensSources.Market.value, source = AnalyticsParam.ScreensSources.Market.value,
) )
}, },
sourceParams = child.source,
), ),
) )
} }

View file

@ -148,6 +148,6 @@ internal class DefaultEarnComponent(
data class Params( data class Params(
val onBackClick: () -> Unit, val onBackClick: () -> Unit,
val onSearchClicked: () -> Unit, val onSearchClicked: (source: String) -> Unit,
) )
} }

View file

@ -134,7 +134,7 @@ internal class DefaultMarketsTokenListComponent(
data class ClickIntents( data class ClickIntents(
val onBackClicked: () -> Unit, val onBackClicked: () -> Unit,
val onSearchClicked: () -> Unit, val onSearchClicked: (source: String) -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
) )

View file

@ -120,5 +120,6 @@ internal class DefaultSearchComponent(
data class Params( data class Params(
val onBackClick: () -> Unit, val onBackClick: () -> Unit,
val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val sourceParams: String,
) )
} }

View file

@ -8,6 +8,7 @@ import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.api.AnalyticsEventHandler 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.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
@ -325,7 +326,7 @@ internal class EarnModel @Inject constructor(
onNetworkFilterClick = ::onNetworkFilterClick, onNetworkFilterClick = ::onNetworkFilterClick,
onTypeFilterClick = ::onTypeFilterClick, onTypeFilterClick = ::onTypeFilterClick,
onScroll = ::onMostlyUsedScrolled, onScroll = ::onMostlyUsedScrolled,
onSearchBarClicked = params.onSearchClicked, onSearchBarClicked = { params.onSearchClicked(AnalyticsParam.ScreensSources.Earn.value) },
), ),
) )
} }

View file

@ -240,7 +240,7 @@ internal class FeedComponentModel @Inject constructor(
onBarClick = { onBarClick = {
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
if (designFeatureToggles.isRedesignEnabled) { if (designFeatureToggles.isRedesignEnabled) {
params.feedClickIntents.openSearch() params.feedClickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value)
} else { } else {
params.feedClickIntents.onMarketOpenClick(null) params.feedClickIntents.onMarketOpenClick(null)
} }

View file

@ -30,5 +30,5 @@ internal interface FeedModelClickIntents {
fun onOpenEarnPage() fun onOpenEarnPage()
fun openSearch() fun openSearch(source: String)
} }

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse import arrow.core.getOrElse
import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsEventHandler 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.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
@ -70,7 +71,7 @@ internal class MarketsListModel @Inject constructor(
preselectedInterval = Provider { modelParams.params.preselectedInterval }, preselectedInterval = Provider { modelParams.params.preselectedInterval },
onBackClick = modelParams.clickIntents.onBackClicked, onBackClick = modelParams.clickIntents.onBackClicked,
analyticsEventHandler = analyticsEventHandler, analyticsEventHandler = analyticsEventHandler,
onSearchBarClick = modelParams.clickIntents.onSearchClicked, onSearchBarClick = { modelParams.clickIntents.onSearchClicked(AnalyticsParam.ScreensSources.Market.value) },
) )
} }

View file

@ -141,6 +141,21 @@ internal class MarketsListBatchFlowManager(
initialValue = false, 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 val isSearchNotFoundState = batchFlow.state
.map { batchListState -> .map { batchListState ->
currentSearchText().isNullOrEmpty().not() && currentSearchText().isNullOrEmpty().not() &&

View file

@ -13,6 +13,7 @@ import com.tangem.common.ui.markets.models.MarketsListItemUM
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.datasource.api.common.response.ApiResponseError
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase 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.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM 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.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.converter.*
import com.tangem.features.feed.model.search.state.SearchStateController import com.tangem.features.feed.model.search.state.SearchStateController
import com.tangem.features.feed.model.search.state.transformers.* 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 UPDATE_QUOTES_TIMER_MILLIS = 60000L
private const val MARKET_SEARCH_DEBOUNCE_MS = 500L private const val MARKET_SEARCH_DEBOUNCE_MS = 500L
private const val RESULTS_SHOWN_DEBOUNCE_MS = 1000L
@Suppress("LongParameterList", "LargeClass") @Suppress("LongParameterList", "LargeClass")
@ModelScoped @ModelScoped
@ -63,6 +66,7 @@ internal class SearchModel @Inject constructor(
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val appRouter: AppRouter, private val appRouter: AppRouter,
private val stateController: SearchStateController, private val stateController: SearchStateController,
private val searchAnalyticsHelper: SearchAnalyticsHelper,
) : Model() { ) : Model() {
private val params = paramsContainer.require<DefaultSearchComponent.Params>() private val params = paramsContainer.require<DefaultSearchComponent.Params>()
@ -71,7 +75,6 @@ internal class SearchModel @Inject constructor(
private val searchResultsJob = JobHolder() private val searchResultsJob = JobHolder()
private val marketSearchDebounceJob = JobHolder() private val marketSearchDebounceJob = JobHolder()
private var shouldShowAllTokensIncludingUnder100k = false private var shouldShowAllTokensIncludingUnder100k = false
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default } maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn( }.stateIn(
@ -122,7 +125,10 @@ internal class SearchModel @Inject constructor(
subscribeToMarketUiItems() subscribeToMarketUiItems()
subscribeToQuotesPolling() subscribeToQuotesPolling()
subscribeToAppCurrencyChanges() subscribeToAppCurrencyChanges()
subscribeToMarketLoadingErrors()
subscribeToResultsShown()
loadHistory() loadHistory()
searchAnalyticsHelper.sendSearchScreenOpened(params.sourceParams)
} }
fun loadMore() { fun loadMore() {
@ -138,11 +144,14 @@ internal class SearchModel @Inject constructor(
fun clearSearchHistory() { fun clearSearchHistory() {
modelScope.launch(dispatchers.default) { modelScope.launch(dispatchers.default) {
clearSearchHistoryUseCase() clearSearchHistoryUseCase()
searchAnalyticsHelper.sendClearButtonClicked()
} }
} }
fun onTextHintClick(text: String) { fun onTextHintClick(text: String) {
stateController.update(UpdateSearchBarQueryTransformer(text)) stateController.update(UpdateSearchBarQueryTransformer(text))
searchAnalyticsHelper.sendHintClicked(text)
searchAnalyticsHelper.sendSearchStarted()
} }
fun onResultMarketTokenClick(item: MarketsListItemUM) { fun onResultMarketTokenClick(item: MarketsListItemUM) {
@ -155,6 +164,7 @@ internal class SearchModel @Inject constructor(
) )
saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input)) saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input))
saveSearchQueryUseCase(stateController.value.searchBar.query) saveSearchQueryUseCase(stateController.value.searchBar.query)
searchAnalyticsHelper.sendMarketItemClicked(item.currencySymbol)
withContext(dispatchers.mainImmediate) { withContext(dispatchers.mainImmediate) {
searchMarketsListManager.getTokenById(item.id)?.let { found -> searchMarketsListManager.getTokenById(item.id)?.let { found ->
params.onMarketTokenClick(found.toSerializableParam(), appCurrency) params.onMarketTokenClick(found.toSerializableParam(), appCurrency)
@ -176,6 +186,7 @@ internal class SearchModel @Inject constructor(
), ),
imageUrl = item.iconUrl, imageUrl = item.iconUrl,
) )
searchAnalyticsHelper.sendRecentItemClicked(item.currencySymbol)
params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value) params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value)
} }
@ -195,6 +206,7 @@ internal class SearchModel @Inject constructor(
private fun onQueryChange(query: String) { private fun onQueryChange(query: String) {
stateController.update(UpdateSearchBarQueryTransformer(query)) stateController.update(UpdateSearchBarQueryTransformer(query))
searchAnalyticsHelper.sendSearchStarted()
} }
private fun onClearClick() { private fun onClearClick() {
@ -202,6 +214,7 @@ internal class SearchModel @Inject constructor(
} }
private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) { private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) {
searchAnalyticsHelper.sendPortfolioItemClicked(entry.currencyStatus.currency.symbol)
appRouter.push( appRouter.push(
AppRoute.CurrencyDetails( AppRoute.CurrencyDetails(
userWalletId = entry.userWalletId, userWalletId = entry.userWalletId,
@ -211,6 +224,7 @@ internal class SearchModel @Inject constructor(
} }
private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) { private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) {
searchAnalyticsHelper.sendGroupClicked(grouped.tokenSymbol)
bottomSheetNavigation.activate( bottomSheetNavigation.activate(
SearchBottomSheetRoute.TokenSelector( SearchBottomSheetRoute.TokenSelector(
entries = grouped.entries, entries = grouped.entries,
@ -291,6 +305,50 @@ internal class SearchModel @Inject constructor(
}.launchIn(modelScope) }.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() { private fun subscribeToMarketUiItems() {
combine( combine(
flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(), flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(),
@ -312,6 +370,7 @@ internal class SearchModel @Inject constructor(
) )
} }
.filterNotNull() .filterNotNull()
.distinctUntilChanged()
.onEach { snapshot -> .onEach { snapshot ->
stateController.update(ApplySearchMarketBatchTransformer(snapshot)) stateController.update(ApplySearchMarketBatchTransformer(snapshot))
} }

View file

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

View file

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