Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-24 13:11:50 +01:00
parent b74acabcc4
commit 4649abbb60
21 changed files with 627 additions and 0 deletions

View file

@ -161,6 +161,7 @@ dependencies {
implementation(projects.domain.news) implementation(projects.domain.news)
implementation(projects.domain.earn) implementation(projects.domain.earn)
implementation(projects.domain.tokensync) implementation(projects.domain.tokensync)
implementation(projects.domain.search)
implementation(projects.common) implementation(projects.common)
implementation(projects.common.routing) implementation(projects.common.routing)
@ -216,6 +217,7 @@ dependencies {
implementation(projects.data.hotWallet) implementation(projects.data.hotWallet)
implementation(projects.data.news) implementation(projects.data.news)
implementation(projects.data.earn) implementation(projects.data.earn)
implementation(projects.data.search)
/** Features */ /** Features */
implementation(projects.features.referral.impl) implementation(projects.features.referral.impl)

1
data/search/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,40 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.search"
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.search)
implementation(projects.domain.common)
implementation(projects.domain.account.status)
implementation(projects.domain.markets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.appCurrency)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi.kotlin)
// endregion
}

View file

@ -0,0 +1,41 @@
package com.tangem.data.search.converter
import com.tangem.data.search.model.RecentTokenDTO
import com.tangem.data.search.model.TextHintDTO
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.search.model.RecentSearchToken
import com.tangem.domain.search.model.SearchTextHint
import com.tangem.utils.converter.Converter
internal class TextHintDTOToSearchTextHintConverter : Converter<TextHintDTO, SearchTextHint> {
override fun convert(value: TextHintDTO): SearchTextHint {
return SearchTextHint(
text = value.text,
timestamp = value.timestamp,
)
}
}
internal class RecentTokenDTOToRecentSearchTokenConverter : Converter<RecentTokenDTO, RecentSearchToken> {
override fun convert(value: RecentTokenDTO): RecentSearchToken {
return RecentSearchToken(
id = CryptoCurrency.RawID(value.id),
name = value.name,
symbol = value.symbol,
imageUrl = value.imageUrl,
timestamp = value.timestamp,
)
}
}
internal class RecentSearchTokenToRecentTokenDTOConverter : Converter<RecentSearchToken, RecentTokenDTO> {
override fun convert(value: RecentSearchToken): RecentTokenDTO {
return RecentTokenDTO(
id = value.id.value,
name = value.name,
symbol = value.symbol,
imageUrl = value.imageUrl,
timestamp = value.timestamp,
)
}
}

View file

@ -0,0 +1,98 @@
package com.tangem.data.search.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.data.search.model.SearchHistoryDTO
import com.tangem.data.search.repository.DefaultSearchRepository
import com.tangem.data.search.store.DefaultSearchHistoryStore
import com.tangem.data.search.store.SearchHistoryStore
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.search.repository.SearchRepository
import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase
import com.tangem.domain.search.usecase.GetSearchResultsUseCase
import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase
import com.tangem.domain.search.usecase.SaveSearchQueryUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object SearchDataModule {
@OptIn(ExperimentalStdlibApi::class)
@Provides
@Singleton
fun provideSearchHistoryDataStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
): DataStore<SearchHistoryDTO> {
return DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
defaultValue = SearchHistoryDTO(),
adapter = moshi.adapter<SearchHistoryDTO>(),
),
produceFile = { context.dataStoreFile(fileName = "search_history") },
scope = appScope,
)
}
@Provides
@Singleton
fun provideSearchHistoryStore(dataStore: DataStore<SearchHistoryDTO>): SearchHistoryStore {
return DefaultSearchHistoryStore(dataStore = dataStore)
}
@Provides
@Singleton
fun provideSearchRepository(
store: SearchHistoryStore,
dispatchers: CoroutineDispatcherProvider,
): SearchRepository {
return DefaultSearchRepository(
store = store,
dispatchers = dispatchers,
)
}
@Provides
fun provideGetSearchResultsUseCase(
searchRepository: SearchRepository,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
userWalletsListRepository: UserWalletsListRepository,
): GetSearchResultsUseCase {
return GetSearchResultsUseCase(
searchRepository = searchRepository,
multiAccountStatusListSupplier = multiAccountStatusListSupplier,
userWalletsListRepository = userWalletsListRepository,
)
}
@Provides
fun provideSaveSearchQueryUseCase(searchRepository: SearchRepository): SaveSearchQueryUseCase {
return SaveSearchQueryUseCase(searchRepository = searchRepository)
}
@Provides
fun provideSaveRecentSearchTokenUseCase(searchRepository: SearchRepository): SaveRecentSearchTokenUseCase {
return SaveRecentSearchTokenUseCase(searchRepository = searchRepository)
}
@Provides
fun provideClearSearchHistoryUseCase(searchRepository: SearchRepository): ClearSearchHistoryUseCase {
return ClearSearchHistoryUseCase(searchRepository = searchRepository)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.search.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class SearchHistoryDTO(
val textHints: List<TextHintDTO> = emptyList(),
val recentTokens: List<RecentTokenDTO> = emptyList(),
)
@JsonClass(generateAdapter = true)
internal data class TextHintDTO(
val text: String,
val timestamp: Long,
)
@JsonClass(generateAdapter = true)
internal data class RecentTokenDTO(
val id: String,
val name: String,
val symbol: String,
val imageUrl: String?,
val timestamp: Long,
)

View file

@ -0,0 +1,60 @@
package com.tangem.data.search.repository
import com.tangem.data.search.converter.RecentSearchTokenToRecentTokenDTOConverter
import com.tangem.data.search.converter.RecentTokenDTOToRecentSearchTokenConverter
import com.tangem.data.search.converter.TextHintDTOToSearchTextHintConverter
import com.tangem.data.search.model.TextHintDTO
import com.tangem.data.search.store.SearchHistoryStore
import com.tangem.domain.search.model.RecentSearchToken
import com.tangem.domain.search.model.SearchTextHint
import com.tangem.domain.search.repository.SearchRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
internal class DefaultSearchRepository(
private val store: SearchHistoryStore,
private val dispatchers: CoroutineDispatcherProvider,
) : SearchRepository {
private val textHintConverter by lazy {
TextHintDTOToSearchTextHintConverter()
}
private val recentTokenConverter by lazy {
RecentTokenDTOToRecentSearchTokenConverter()
}
private val recentTokenDMConverter by lazy {
RecentSearchTokenToRecentTokenDTOConverter()
}
override fun getTextHints(): Flow<List<SearchTextHint>> {
return store.getTextHints()
.map { textHintConverter.convertList(it) }
.flowOn(dispatchers.io)
}
override fun getRecentTokens(): Flow<List<RecentSearchToken>> {
return store.getRecentTokens()
.map { recentTokenConverter.convertList(it) }
.flowOn(dispatchers.io)
}
override suspend fun saveTextHint(text: String) = withContext(dispatchers.io) {
store.saveTextHint(
TextHintDTO(
text = text,
timestamp = System.currentTimeMillis(),
),
)
}
override suspend fun saveRecentToken(token: RecentSearchToken) = withContext(dispatchers.io) {
store.saveRecentToken(recentTokenDMConverter.convert(token))
}
override suspend fun clearHistory() = withContext(dispatchers.io) {
store.clearAll()
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.data.search.store
import androidx.datastore.core.DataStore
import com.tangem.data.search.model.RecentTokenDTO
import com.tangem.data.search.model.SearchHistoryDTO
import com.tangem.data.search.model.TextHintDTO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultSearchHistoryStore(
private val dataStore: DataStore<SearchHistoryDTO>,
) : SearchHistoryStore {
override fun getTextHints(): Flow<List<TextHintDTO>> {
return dataStore.data.map { it.textHints.sortedByDescending(TextHintDTO::timestamp) }
}
override fun getRecentTokens(): Flow<List<RecentTokenDTO>> {
return dataStore.data.map { it.recentTokens.sortedByDescending(RecentTokenDTO::timestamp) }
}
override suspend fun saveTextHint(hint: TextHintDTO) {
dataStore.updateData { current ->
val updated = current.textHints
.filter { it.text != hint.text }
.toMutableList()
.apply { add(0, hint) }
.take(MAX_HISTORY_SIZE)
current.copy(textHints = updated)
}
}
override suspend fun saveRecentToken(token: RecentTokenDTO) {
dataStore.updateData { current ->
val updated = current.recentTokens
.filter { it.id != token.id }
.toMutableList()
.apply { add(0, token) }
.take(MAX_HISTORY_SIZE)
current.copy(recentTokens = updated)
}
}
override suspend fun clearAll() {
dataStore.updateData {
SearchHistoryDTO()
}
}
private companion object {
const val MAX_HISTORY_SIZE = 3
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.data.search.store
import com.tangem.data.search.model.RecentTokenDTO
import com.tangem.data.search.model.TextHintDTO
import kotlinx.coroutines.flow.Flow
internal interface SearchHistoryStore {
fun getTextHints(): Flow<List<TextHintDTO>>
fun getRecentTokens(): Flow<List<RecentTokenDTO>>
suspend fun saveTextHint(hint: TextHintDTO)
suspend fun saveRecentToken(token: RecentTokenDTO)
suspend fun clearAll()
}

1
domain/search/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,20 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.search"
}
dependencies {
api(projects.domain.core)
api(projects.domain.models)
implementation(projects.domain.common)
implementation(projects.domain.markets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.appCurrency)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.search.model
import com.tangem.domain.models.currency.CryptoCurrency
/**
* @property timestamp epoch milliseconds
*/
data class RecentSearchToken(
val id: CryptoCurrency.RawID,
val name: String,
val symbol: String,
val imageUrl: String?,
val timestamp: Long,
)

View file

@ -0,0 +1,10 @@
package com.tangem.domain.search.model
import com.tangem.domain.markets.TokenMarket
data class SearchResult(
val textHints: List<SearchTextHint>,
val recentTokens: List<RecentSearchToken>,
val userAssets: List<UserAssetSearchEntry>,
val marketTokens: List<TokenMarket>,
)

View file

@ -0,0 +1,6 @@
package com.tangem.domain.search.model
/**
* @property timestamp epoch milliseconds
*/
data class SearchTextHint(val text: String, val timestamp: Long)

View file

@ -0,0 +1,14 @@
package com.tangem.domain.search.model
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
data class UserAssetSearchEntry(
val userWalletId: UserWalletId,
val userWalletName: String,
val accountId: AccountId,
val accountName: AccountName,
val currencyStatus: CryptoCurrencyStatus,
)

View file

@ -0,0 +1,39 @@
package com.tangem.domain.search.repository
import com.tangem.domain.search.model.RecentSearchToken
import com.tangem.domain.search.model.SearchTextHint
import kotlinx.coroutines.flow.Flow
/**
* Repository responsible for managing local search history storage.
* Handles persistence of user's past search queries and recently viewed market tokens.
* Each history type is limited to 3 entries, sorted by timestamp in descending order.
*/
interface SearchRepository {
/** Observes the list of saved text hints, sorted by timestamp descending. */
fun getTextHints(): Flow<List<SearchTextHint>>
/** Observes the list of recently viewed market tokens, sorted by timestamp descending. */
fun getRecentTokens(): Flow<List<RecentSearchToken>>
/**
* Saves a text hint to the search history.
* If the hint already exists, its timestamp is updated. Oldest entries are evicted when the limit is exceeded.
*
* @param text the search query text to save
*/
suspend fun saveTextHint(text: String)
/**
* Saves a recently viewed market token to the search history.
* If a token with the same ID already exists, it is moved to the top. Oldest entries are evicted when the limit
* is exceeded.
*
* @param token the market token entry to save
*/
suspend fun saveRecentToken(token: RecentSearchToken)
/** Clears all search history, including both text hints and recent tokens. */
suspend fun clearHistory()
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.search.usecase
import com.tangem.domain.search.repository.SearchRepository
/**
* Clears the entire search history, removing both text hints and recently viewed tokens.
*
* @property searchRepository local search history storage
*/
class ClearSearchHistoryUseCase(
private val searchRepository: SearchRepository,
) {
suspend operator fun invoke() {
searchRepository.clearHistory()
}
}

View file

@ -0,0 +1,126 @@
package com.tangem.domain.search.usecase
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.search.model.SearchResult
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.repository.SearchRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
/**
* Primary search use case that produces [SearchResult] based on the current query.
*
* Behavior depends on the query:
* - **Empty query** returns search history: text hints and recently viewed tokens.
* - **Non-empty query** performs the search across all unlocked user wallets,
* matching currencies by name or symbol, and combines the results with externally provided market tokens.
*
* @property searchRepository local search history storage
* @property multiAccountStatusListSupplier supplier for loaded account status lists across all wallets
* @property userWalletsListRepository repository providing the list of user wallets
*/
class GetSearchResultsUseCase(
private val searchRepository: SearchRepository,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val userWalletsListRepository: UserWalletsListRepository,
) {
/**
* Produces a [Flow] of [SearchResult] for the given [query].
*
* @param query the search query string; blank means "show history"
* @param marketTokens external flow of market token search results (provided by presentation layer)
*/
operator fun invoke(
query: String,
marketTokens: Flow<List<TokenMarket>> = flowOf(emptyList()),
): Flow<SearchResult> {
return if (query.isBlank()) {
observeHistory()
} else {
searchAssets(query, marketTokens)
}
}
private fun observeHistory(): Flow<SearchResult> {
return combine(
searchRepository.getTextHints(),
searchRepository.getRecentTokens(),
) { hints, tokens ->
SearchResult(
textHints = hints,
recentTokens = tokens,
userAssets = emptyList(),
marketTokens = emptyList(),
)
}
}
private fun searchAssets(query: String, marketTokens: Flow<List<TokenMarket>>): Flow<SearchResult> {
return combine(
observeUserAssets(query),
marketTokens,
) { userAssets, markets ->
SearchResult(
textHints = emptyList(),
recentTokens = emptyList(),
userAssets = userAssets,
marketTokens = markets,
)
}
}
private fun observeUserAssets(query: String): Flow<List<UserAssetSearchEntry>> {
val lowerQuery = query.lowercase()
return combine(
multiAccountStatusListSupplier(),
userWalletsListRepository.userWallets,
) { statusLists, wallets ->
val unlockedWallets = wallets
.orEmpty()
.filterNot(UserWallet::isLocked)
.associateBy { it.walletId }
if (unlockedWallets.isEmpty()) return@combine emptyList()
statusLists
.filter { it.userWalletId in unlockedWallets }
.flatMap { statusList -> extractMatchingAssets(statusList, unlockedWallets, lowerQuery) }
}
}
private fun extractMatchingAssets(
statusList: AccountStatusList,
wallets: Map<UserWalletId, UserWallet>,
lowerQuery: String,
): List<UserAssetSearchEntry> {
val wallet = wallets[statusList.userWalletId] ?: return emptyList()
return statusList.accountStatuses
.filterCryptoPortfolio()
.flatMap { accountStatus ->
accountStatus.flattenCurrencies()
.filter { currencyStatus ->
val name = currencyStatus.currency.name.lowercase()
val symbol = currencyStatus.currency.symbol.lowercase()
name.contains(lowerQuery) || symbol.contains(lowerQuery)
}
.map { currencyStatus ->
UserAssetSearchEntry(
userWalletId = statusList.userWalletId,
userWalletName = wallet.name,
accountId = accountStatus.accountId,
accountName = accountStatus.account.accountName,
currencyStatus = currencyStatus,
)
}
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.search.usecase
import com.tangem.domain.search.model.RecentSearchToken
import com.tangem.domain.search.repository.SearchRepository
/**
* Saves a market token to the "recently viewed" search history.
* Only market assets should be saved (user's own assets are ignored).
* The history is limited to 3 entries; oldest entries are evicted automatically.
*
* @property searchRepository local search history storage
*/
class SaveRecentSearchTokenUseCase(
private val searchRepository: SearchRepository,
) {
/**
* @param token the market token to persist as a recent search entry
*/
suspend operator fun invoke(token: RecentSearchToken) {
searchRepository.saveRecentToken(token)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.search.usecase
import com.tangem.domain.search.repository.SearchRepository
/**
* Saves the current search query text to the local search history.
* Blank queries are ignored. The history is limited to 3 entries; oldest entries are evicted automatically.
* Should be called when the user selects any asset from the search results.
*
* @property searchRepository local search history storage
*/
class SaveSearchQueryUseCase(
private val searchRepository: SearchRepository,
) {
/**
* @param query the search text to persist; blank values are silently ignored
*/
suspend operator fun invoke(query: String) {
if (query.isBlank()) return
searchRepository.saveTextHint(query.trim())
}
}

View file

@ -392,6 +392,7 @@ include(":domain:yield-supply")
include(":domain:yield-supply:models") include(":domain:yield-supply:models")
include(":domain:news") include(":domain:news")
include(":domain:earn") include(":domain:earn")
include(":domain:search")
// endregion Domain modules // endregion Domain modules
// region Data modules // region Data modules
@ -430,4 +431,5 @@ include(":data:wallet-manager")
include(":data:yield-supply") include(":data:yield-supply")
include(":data:news") include(":data:news")
include(":data:earn") include(":data:earn")
include(":data:search")
// endregion Data modules // endregion Data modules