From 7b182e97b3920f60a023317d5281bc06e462bc2d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 2 Sep 2026 19:53:24 +0800 Subject: [PATCH 01/12] fix(market): merge local and online search results --- .../android/extension/IterableExtension.kt | 23 +++ .../repository/PerpsMarketRepository.kt | 35 +++- .../home/web3/market/MarketSearchViewModel.kt | 54 ++++-- .../android/db/perps/PerpsMarketDaoTest.kt | 33 ++++ .../repository/PerpsMarketRepositoryTest.kt | 144 ++++++++++++++++ .../market/MarketSearchViewModelSearchTest.kt | 162 ++++++++++++++++++ 6 files changed, 439 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt diff --git a/app/src/main/java/one/mixin/android/extension/IterableExtension.kt b/app/src/main/java/one/mixin/android/extension/IterableExtension.kt index c46c8cc533..0659c4f0ee 100644 --- a/app/src/main/java/one/mixin/android/extension/IterableExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/IterableExtension.kt @@ -8,3 +8,26 @@ suspend fun Iterable.pmap(f: suspend (A) -> B): List = coroutineScope { map { async { f(it) } }.awaitAll() } + +fun mergeLocalAndRefreshed( + localMatches: List, + refreshedMatches: List, + keySelector: (T) -> K, +): List { + val refreshedByKey = refreshedMatches.associateBy(keySelector) + val localKeys = LinkedHashSet() + val merged = ArrayList(localMatches.size + refreshedMatches.size) + + localMatches.forEach { local -> + val key = keySelector(local) + localKeys += key + merged += refreshedByKey[key] ?: local + } + refreshedMatches.forEach { refreshed -> + if (keySelector(refreshed) !in localKeys) { + merged += refreshed + } + } + + return merged +} diff --git a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt index 217aea60ec..ad30490a59 100644 --- a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt @@ -1,6 +1,7 @@ package one.mixin.android.repository import androidx.room.withTransaction +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import one.mixin.android.Constants import one.mixin.android.api.response.perps.PerpsFavorite @@ -12,6 +13,7 @@ import one.mixin.android.db.PerpsDatabase import one.mixin.android.db.perps.PerpsFavoriteDao import one.mixin.android.db.perps.PerpsMarketCategoryDao import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.extension.mergeLocalAndRefreshed import one.mixin.android.extension.nowInUtc import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.vo.market.MarketCategory @@ -40,6 +42,13 @@ class PerpsMarketRepository suspend fun searchMarkets(query: String): List = marketDao.searchMarkets(query.trim()) + suspend fun searchMarketsOnlineFirst(query: String): List = + searchPerpsMarketsOnlineFirst( + query = query, + searchLocalMarkets = marketDao::searchMarkets, + syncOnlineMarkets = { syncAllMarkets() }, + ) + suspend fun getMarket(marketId: String): PerpsMarket? = marketDao.getMarket(marketId) suspend fun syncAllMarkets(): List? { @@ -167,7 +176,10 @@ class PerpsMarketRepository response.data.orEmpty().map(PerpsMarket::withDefaults) }, failureBlock = { true }, - exceptionBlock = { true }, + exceptionBlock = { throwable -> + if (throwable is CancellationException) throw throwable + true + }, defaultErrorHandle = {}, defaultExceptionHandle = {}, requestSession = { @@ -179,3 +191,24 @@ class PerpsMarketRepository const val CATEGORY_FAVORITE = "favorite" } } + +internal suspend fun searchPerpsMarketsOnlineFirst( + query: String, + searchLocalMarkets: suspend (query: String) -> List, + syncOnlineMarkets: suspend () -> Unit, +): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) return emptyList() + + val localMatches = searchLocalMarkets(normalizedQuery) + + return try { + syncOnlineMarkets() + val refreshedMatches = searchLocalMarkets(normalizedQuery) + mergeLocalAndRefreshed(localMatches, refreshedMatches, PerpsMarket::marketId) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + localMatches + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index fa7490de13..cadf36653a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import one.mixin.android.Constants.Account.PREF_MARKET_RECENT_SEARCH import one.mixin.android.Constants.Account.PREF_RECENT_SEARCH import one.mixin.android.extension.escapeSql +import one.mixin.android.extension.mergeLocalAndRefreshed import one.mixin.android.extension.putString import one.mixin.android.extension.remove import one.mixin.android.repository.PerpsMarketRepository @@ -27,6 +28,7 @@ import one.mixin.android.repository.TokenRepository import one.mixin.android.util.GsonHelper import one.mixin.android.vo.RecentSearch import one.mixin.android.vo.RecentSearchType +import one.mixin.android.vo.market.Market import one.mixin.android.vo.market.MarketCategory import one.mixin.android.vo.market.MarketItem import javax.inject.Inject @@ -205,15 +207,18 @@ internal class MarketSearchViewModel private suspend fun searchSpotMarkets(query: String): List = try { - val escapedQuery = query.escapeSql() - var markets = tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal()) - if (markets.isEmpty()) { - tokenRepository.searchMarket(query) - markets = tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal()) - } - markets.map { market -> - tokenRepository.findMarketItemByCoinId(market.coinId) ?: MarketItem.fromMarket(market) - } + searchSpotMarketsOnlineFirst( + query = query, + searchLocalMarkets = { escapedQuery -> + tokenRepository.fuzzyMarkets(escapedQuery, CancellationSignal()) + }, + refreshOnlineMarkets = { normalizedQuery -> + tokenRepository.searchMarket(normalizedQuery) + }, + resolveMarketItem = { market -> + tokenRepository.findMarketItemByCoinId(market.coinId) ?: MarketItem.fromMarket(market) + }, + ) } catch (e: CancellationException) { throw e } catch (_: Exception) { @@ -222,11 +227,38 @@ internal class MarketSearchViewModel private suspend fun searchPerpetualMarkets(query: String) = try { - initialPerpetualSyncJob.join() - perpsMarketRepository.searchMarkets(query) + perpsMarketRepository.searchMarketsOnlineFirst(query) } catch (e: CancellationException) { throw e } catch (_: Exception) { emptyList() } +} + +internal suspend fun searchSpotMarketsOnlineFirst( + query: String, + searchLocalMarkets: suspend (escapedQuery: String) -> List, + refreshOnlineMarkets: suspend (query: String) -> Unit, + resolveMarketItem: suspend (Market) -> MarketItem, +): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) return emptyList() + + val escapedQuery = normalizedQuery.escapeSql() + val localMatches = searchLocalMarkets(escapedQuery) + + val markets = + try { + refreshOnlineMarkets(normalizedQuery) + val refreshedMatches = searchLocalMarkets(escapedQuery) + mergeLocalAndRefreshed(localMatches, refreshedMatches, Market::coinId) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + localMatches + } + + return markets.map { market -> + resolveMarketItem(market) } +} diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt index 5447e026f4..3428d34a71 100644 --- a/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt @@ -98,6 +98,39 @@ class PerpsMarketDaoTest { assertEquals(listOf("btc-usdt", "eth-btc"), result.map(PerpsMarket::marketId)) } + @Test + fun upsertedRefreshedMarketsBecomeSearchableWithoutClearingDatabase() = + runBlocking { + database.perpsMarketDao().upsertList( + listOf( + market("eth-usdt", volume = "50", score = 0).copy( + displaySymbol = "ETHUSDT", + tokenSymbol = "ETH", + quoteSymbol = "USDT", + ), + ), + ) + + database.perpsMarketDao().upsertList( + listOf( + market("btc-usdt", volume = "100", score = 0).copy( + displaySymbol = "BTCUSDT", + tokenSymbol = "BTC", + quoteSymbol = "USDT", + ), + market("eth-usdt", volume = "60", score = 0).copy( + displaySymbol = "ETHUSDT", + tokenSymbol = "ETH", + quoteSymbol = "USDT", + ), + ), + ) + + val result = database.perpsMarketDao().searchMarkets("btc") + + assertEquals(listOf("btc-usdt"), result.map(PerpsMarket::marketId)) + } + @Test fun favoriteMarketsOrderByNewestAddition() = runBlocking { diff --git a/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt b/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt new file mode 100644 index 0000000000..8965dd49f5 --- /dev/null +++ b/app/src/test/java/one/mixin/android/repository/PerpsMarketRepositoryTest.kt @@ -0,0 +1,144 @@ +package one.mixin.android.repository + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import one.mixin.android.api.response.perps.PerpsMarket +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PerpsMarketRepositoryTest { + @Test + fun perpsSearch_callsSyncEvenWhenLocalMatchesAreNonEmpty() = + runBlocking { + val local = listOf(market("btc-usdt")) + var syncCalls = 0 + + searchPerpsMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { local }, + syncOnlineMarkets = { syncCalls += 1 }, + ) + + assertEquals(1, syncCalls) + } + + @Test + fun perpsSearch_appendsRemoteOnlyMatchesAfterSync() = + runBlocking { + val initialLocal = listOf(market("btc-usdt")) + val refreshedLocal = listOf(market("btc-usdt"), market("btc-usdc")) + var searchCalls = 0 + + val result = + searchPerpsMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) initialLocal else refreshedLocal + }, + syncOnlineMarkets = {}, + ) + + assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(PerpsMarket::marketId)) + } + + @Test + fun perpsSearch_preservesLocalOrderButUsesRefreshedOverlappingMatchesByMarketId() = + runBlocking { + val staleLocal = listOf(market("eth-usdt").copy(last = "1"), market("btc-usdt").copy(last = "1")) + val refreshedLocal = + listOf( + market("eth-usdt").copy(last = "2"), + market("btc-usdt").copy(last = "3"), + market("sol-usdt"), + ) + var searchCalls = 0 + + val result = + searchPerpsMarketsOnlineFirst( + query = "usdt", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) staleLocal else refreshedLocal + }, + syncOnlineMarkets = {}, + ) + + assertEquals(listOf("eth-usdt", "btc-usdt", "sol-usdt"), result.map(PerpsMarket::marketId)) + assertEquals("2", result[0].last) + assertEquals("3", result[1].last) + } + + @Test + fun perpsSearch_returnsInitialLocalMatchesWhenSyncFails() = + runBlocking { + val local = listOf(market("btc-usdt")) + + val result = + searchPerpsMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { local }, + syncOnlineMarkets = { error("offline") }, + ) + + assertEquals(listOf("btc-usdt"), result.map(PerpsMarket::marketId)) + } + + @Test(expected = CancellationException::class) + fun perpsSearch_propagatesCancellation() { + runBlocking { + searchPerpsMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { listOf(market("btc-usdt")) }, + syncOnlineMarkets = { throw CancellationException("cancelled") }, + ) + } + } + + @Test + fun perpsSearch_blankQuerySkipsLocalAndSyncCalls() = + runBlocking { + var localCalls = 0 + var syncCalls = 0 + + val result = + searchPerpsMarketsOnlineFirst( + query = " ", + searchLocalMarkets = { + localCalls += 1 + emptyList() + }, + syncOnlineMarkets = { syncCalls += 1 }, + ) + + assertTrue(result.isEmpty()) + assertEquals(0, localCalls) + assertEquals(0, syncCalls) + } + + private fun market(marketId: String) = + PerpsMarket( + marketId = marketId, + displaySymbol = marketId, + tokenSymbol = marketId, + quoteSymbol = "USD", + markPrice = "1", + leverage = 10, + iconUrl = "", + fundingRate = "0", + minAmount = "0", + maxAmount = "0", + last = "1", + volume = "1", + tradeVolumeScore1D = 0, + high = "1", + low = "1", + open = "1", + change = "0", + bidPrice = "1", + askPrice = "1", + createdAt = "", + updatedAt = "", + ) +} diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt new file mode 100644 index 0000000000..4ec36ce6c8 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt @@ -0,0 +1,162 @@ +package one.mixin.android.ui.home.web3.market + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import one.mixin.android.vo.market.Market +import one.mixin.android.vo.market.MarketItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarketSearchViewModelSearchTest { + @Test + fun spotSearch_callsRemoteEvenWhenLocalMatchesAreNonEmpty() = + runBlocking { + val local = listOf(market("btc-local")) + var remoteCalls = 0 + + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { local }, + refreshOnlineMarkets = { remoteCalls++ }, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals(1, remoteCalls) + } + + @Test + fun spotSearch_appendsRemoteOnlyMatchesAfterRefresh() = + runBlocking { + val initialLocal = listOf(market("btc-usdt")) + val refreshedLocal = listOf(market("btc-usdt"), market("btc-usdc")) + var searchCalls = 0 + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) initialLocal else refreshedLocal + }, + refreshOnlineMarkets = {}, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(MarketItem::coinId)) + } + + @Test + fun spotSearch_preservesLocalOrderButUsesRefreshedOverlappingMatches() = + runBlocking { + val staleLocal = listOf(market("btc").copy(name = "Local BTC"), market("eth").copy(name = "Local ETH")) + val refreshedLocal = + listOf( + market("eth").copy(name = "Remote ETH"), + market("btc").copy(name = "Remote BTC"), + market("btc-cash"), + ) + var searchCalls = 0 + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) staleLocal else refreshedLocal + }, + refreshOnlineMarkets = {}, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals(listOf("btc", "eth", "btc-cash"), result.map(MarketItem::coinId)) + assertEquals("Remote BTC", result.first().name) + assertEquals("Remote ETH", result[1].name) + } + + @Test + fun spotSearch_returnsLocalMatchesWhenRemoteRefreshFails() = + runBlocking { + val local = listOf(market("btc")) + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { local }, + refreshOnlineMarkets = { error("offline") }, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals(listOf("btc"), result.map(MarketItem::coinId)) + } + + @Test(expected = CancellationException::class) + fun spotSearch_propagatesCancellation() { + runBlocking { + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { listOf(market("btc")) }, + refreshOnlineMarkets = { throw CancellationException("cancelled") }, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + } + } + + @Test + fun spotSearch_blankQuerySkipsLocalAndRemoteSearch() = + runBlocking { + var localCalls = 0 + var remoteCalls = 0 + + val result = + searchSpotMarketsOnlineFirst( + query = " ", + searchLocalMarkets = { + localCalls += 1 + emptyList() + }, + refreshOnlineMarkets = { remoteCalls += 1 }, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertTrue(result.isEmpty()) + assertEquals(0, localCalls) + assertEquals(0, remoteCalls) + } + + private fun market(coinId: String) = + Market( + coinId = coinId, + name = coinId.uppercase(), + symbol = coinId.uppercase(), + iconUrl = "", + currentPrice = "1", + marketCap = "1", + marketCapRank = "1", + totalVolume = "1", + high24h = "1", + low24h = "1", + priceChange24h = "0", + priceChangePercentage1H = "0", + priceChangePercentage24H = "0", + priceChangePercentage7D = "0", + priceChangePercentage30D = "0", + marketCapChange24h = "0", + marketCapChangePercentage24h = "0", + circulatingSupply = "1", + totalSupply = "1", + maxSupply = "1", + ath = "1", + athChangePercentage = "0", + athDate = "", + atl = "1", + atlChangePercentage = "0", + atlDate = "", + assetIds = emptyList(), + sparklineIn7d = "", + sparklineIn24h = "", + updatedAt = "", + descriptions = null, + perpsMarketId = null, + ) +} From a4958584a59f314e670c248f8013ad397aaec319 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 2 Sep 2026 20:29:23 +0800 Subject: [PATCH 02/12] fix(market): align search section spacing --- .../mixin/android/ui/home/web3/market/MarketSearchPage.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index c1e901e622..d5444662da 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -113,6 +114,7 @@ internal fun MarketSearchPage( text = stringResource(R.string.Recent), color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, + modifier = Modifier.padding(vertical = 16.dp), ) IconButton( onClick = onClearRecentSearches, @@ -130,7 +132,7 @@ internal fun MarketSearchPage( FlowRow( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), + .padding(start = 20.dp, end = 20.dp, bottom = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { @@ -157,7 +159,7 @@ internal fun MarketSearchPage( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), + .padding(start = 20.dp, top = 20.dp, end = 20.dp, bottom = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { tabs.forEach { tab -> @@ -429,6 +431,7 @@ private fun MarketRecentSearchChip( fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(min = 34.dp), ) if (recentSearch.type == RecentSearchType.PERPETUAL) { Spacer(modifier = Modifier.width(3.dp)) @@ -454,6 +457,7 @@ private fun MarketRecentSearchChip( maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), + modifier = Modifier.widthIn(min = 34.dp), ) } } From 847a23bdc5b7937ade26492ea2295758b93f3de1 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 3 Sep 2026 19:38:50 +0800 Subject: [PATCH 03/12] fix(market): sort search results by market cap Keep refreshed spot search results ordered by market cap and format perpetual prices without fiat conversion. --- .../ui/home/web3/market/MarketSearchPage.kt | 2 +- .../home/web3/market/MarketSearchViewModel.kt | 9 +++-- .../market/MarketSearchViewModelSearchTest.kt | 37 ++++++++++++++++++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index d5444662da..0a107d9759 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -527,7 +527,7 @@ private fun PerpetualMarketSearchRow( title = market.displaySymbol.ifBlank { market.tokenSymbol }, badge = stringResource(R.string.Perp), volume = formatSearchPerpetualVolume(market.volume), - price = "$${market.last}", + price = formatPerpsMarketListPrice(market.last), change = market.changePercentValue(), quoteColorReversed = quoteColorReversed, onClick = onClick, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index cadf36653a..84385b8633 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -31,6 +31,7 @@ import one.mixin.android.vo.RecentSearchType import one.mixin.android.vo.market.Market import one.mixin.android.vo.market.MarketCategory import one.mixin.android.vo.market.MarketItem +import java.math.BigDecimal import javax.inject.Inject @HiltViewModel @@ -258,7 +259,9 @@ internal suspend fun searchSpotMarketsOnlineFirst( localMatches } - return markets.map { market -> - resolveMarketItem(market) - } + return markets + .sortedByDescending { it.marketCap.toBigDecimalOrNull() ?: BigDecimal.ZERO } + .map { market -> + resolveMarketItem(market) + } } diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt index 4ec36ce6c8..f695228905 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt @@ -46,6 +46,39 @@ class MarketSearchViewModelSearchTest { assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(MarketItem::coinId)) } + @Test + fun spotSearch_sortsCombinedResultsByMarketCapDescending() = + runBlocking { + val initialLocal = + listOf( + market("btc-low", marketCap = "10"), + market("btc-high", marketCap = "100"), + ) + val refreshedLocal = + listOf( + market("btc-high", marketCap = "100"), + market("btc-new", marketCap = "50"), + market("btc-low", marketCap = "10"), + ) + var searchCalls = 0 + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) initialLocal else refreshedLocal + }, + refreshOnlineMarkets = {}, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals( + listOf("btc-high", "btc-new", "btc-low"), + result.map(MarketItem::coinId), + ) + } + @Test fun spotSearch_preservesLocalOrderButUsesRefreshedOverlappingMatches() = runBlocking { @@ -124,14 +157,14 @@ class MarketSearchViewModelSearchTest { assertEquals(0, remoteCalls) } - private fun market(coinId: String) = + private fun market(coinId: String, marketCap: String = "1") = Market( coinId = coinId, name = coinId.uppercase(), symbol = coinId.uppercase(), iconUrl = "", currentPrice = "1", - marketCap = "1", + marketCap = marketCap, marketCapRank = "1", totalVolume = "1", high24h = "1", From 68cdbfe28a1e2d3aaa1852144d4bcd7ca2612758 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 4 Sep 2026 09:14:10 +0800 Subject: [PATCH 04/12] fix(market): align recent chip text --- .../android/ui/home/web3/market/MarketSearchPage.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index 0a107d9759..44e9e0fe5e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -421,8 +421,10 @@ private fun MarketRecentSearchChip( MarketIcon(url = recentSearch.iconUrl.orEmpty(), size = 32.dp) Spacer(modifier = Modifier.width(6.dp)) Column( - modifier = Modifier.wrapContentWidth(), - verticalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier + .wrapContentWidth() + .height(32.dp), + verticalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -431,6 +433,7 @@ private fun MarketRecentSearchChip( fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + lineHeight = 14.sp, modifier = Modifier.widthIn(min = 34.dp), ) if (recentSearch.type == RecentSearchType.PERPETUAL) { @@ -452,8 +455,8 @@ private fun MarketRecentSearchChip( Text( text = formatSearchPercent(search.change), color = searchChangeColor(search.change, quoteColorReversed), - fontSize = 12.sp, - lineHeight = 12.sp, + fontSize = 13.sp, + lineHeight = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), From 8de2ffb03a773338862a834b23b32ac106ee57e9 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 4 Sep 2026 14:37:36 +0800 Subject: [PATCH 05/12] fix(market): rank market search results --- .../ui/home/web3/market/MarketSearchModels.kt | 16 +++++++++ .../home/web3/market/MarketSearchViewModel.kt | 20 +++++++---- .../web3/market/MarketSearchModelsTest.kt | 32 +++++++++++++++-- .../market/MarketSearchViewModelSearchTest.kt | 35 +++++++++++-------- 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt index 7882c64c0d..485be3f533 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt @@ -39,6 +39,22 @@ internal fun List.sortedForTrendingSearch(): List = .thenBy { it.marketId }, ) +internal fun List.sortedForMarketSearch( + query: String, + symbol: (T) -> String, + name: (T) -> String, + volume: (T) -> String, +): List { + val normalizedQuery = query.trim() + return sortedWith( + compareByDescending { symbol(it).equals(normalizedQuery, ignoreCase = true) } + .thenByDescending { name(it).equals(normalizedQuery, ignoreCase = true) } + .thenByDescending { volume(it).toBigDecimalOrNull() ?: BigDecimal.ZERO } + .thenBy { symbol(it).lowercase(Locale.ROOT) } + .thenBy { name(it).lowercase(Locale.ROOT) }, + ) +} + internal data class MarketSearchUiState( val query: String = "", val selectedTab: MarketSearchTab = MarketSearchTab.CRYPTO, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index 84385b8633..9ce888793a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.Constants.Account.PREF_MARKET_RECENT_SEARCH import one.mixin.android.Constants.Account.PREF_RECENT_SEARCH import one.mixin.android.extension.escapeSql @@ -31,7 +32,6 @@ import one.mixin.android.vo.RecentSearchType import one.mixin.android.vo.market.Market import one.mixin.android.vo.market.MarketCategory import one.mixin.android.vo.market.MarketItem -import java.math.BigDecimal import javax.inject.Inject @HiltViewModel @@ -228,7 +228,12 @@ internal class MarketSearchViewModel private suspend fun searchPerpetualMarkets(query: String) = try { - perpsMarketRepository.searchMarketsOnlineFirst(query) + perpsMarketRepository.searchMarketsOnlineFirst(query).sortedForMarketSearch( + query = query, + symbol = PerpsMarket::tokenSymbol, + name = PerpsMarket::displaySymbol, + volume = PerpsMarket::volume, + ) } catch (e: CancellationException) { throw e } catch (_: Exception) { @@ -260,8 +265,11 @@ internal suspend fun searchSpotMarketsOnlineFirst( } return markets - .sortedByDescending { it.marketCap.toBigDecimalOrNull() ?: BigDecimal.ZERO } - .map { market -> - resolveMarketItem(market) - } + .map { market -> resolveMarketItem(market) } + .sortedForMarketSearch( + query = normalizedQuery, + symbol = MarketItem::symbol, + name = MarketItem::name, + volume = MarketItem::totalVolume, + ) } diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt index 220e4eb684..2e57e702f7 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt @@ -47,6 +47,30 @@ class MarketSearchModelsTest { ) } + @Test + fun marketSearchRanksExactFieldsThenVolumeAndAlphabetically() { + val markets = + listOf( + market("volume", volume = "100", tokenSymbol = "BTC-X"), + market("name", volume = "1", tokenSymbol = "AAA", displaySymbol = "BTC"), + market("symbol", volume = "0", tokenSymbol = "BTC"), + market("alpha-b", volume = "10", tokenSymbol = "ETH"), + market("alpha-a", volume = "10", tokenSymbol = "AAA"), + ) + + assertEquals( + listOf("symbol", "name", "volume", "alpha-a", "alpha-b"), + markets + .sortedForMarketSearch( + query = "btc", + symbol = PerpsMarket::tokenSymbol, + name = PerpsMarket::displaySymbol, + volume = PerpsMarket::volume, + ) + .map(PerpsMarket::marketId), + ) + } + @Test fun recentSearchesKeepOnlySpotAndPerpetualMarkets() { val searches = @@ -88,11 +112,13 @@ class MarketSearchModelsTest { private fun market( marketId: String, volume: String, - score: Int, + score: Int = 0, + displaySymbol: String = marketId, + tokenSymbol: String = marketId, ) = PerpsMarket( marketId = marketId, - displaySymbol = marketId, - tokenSymbol = marketId, + displaySymbol = displaySymbol, + tokenSymbol = tokenSymbol, quoteSymbol = "USD", markPrice = "1", leverage = 10, diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt index f695228905..e7dd629732 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt @@ -43,22 +43,23 @@ class MarketSearchViewModelSearchTest { resolveMarketItem = { MarketItem.fromMarket(it) }, ) - assertEquals(listOf("btc-usdt", "btc-usdc"), result.map(MarketItem::coinId)) + assertEquals(listOf("btc-usdc", "btc-usdt"), result.map(MarketItem::coinId)) } @Test - fun spotSearch_sortsCombinedResultsByMarketCapDescending() = + fun spotSearch_sortsByExactSymbolThenNameThenVolume() = runBlocking { val initialLocal = listOf( - market("btc-low", marketCap = "10"), - market("btc-high", marketCap = "100"), + market("volume-low", symbol = "BTC-X", totalVolume = "1"), + market("name-exact", symbol = "AAA", name = "BTC", totalVolume = "1"), ) val refreshedLocal = listOf( - market("btc-high", marketCap = "100"), - market("btc-new", marketCap = "50"), - market("btc-low", marketCap = "10"), + market("volume-low", symbol = "BTC-X", totalVolume = "1"), + market("volume-high", symbol = "BTC-Y", totalVolume = "100"), + market("symbol-exact", symbol = "BTC", totalVolume = "0"), + market("name-exact", symbol = "AAA", name = "BTC", totalVolume = "1"), ) var searchCalls = 0 @@ -74,7 +75,7 @@ class MarketSearchViewModelSearchTest { ) assertEquals( - listOf("btc-high", "btc-new", "btc-low"), + listOf("symbol-exact", "name-exact", "volume-high", "volume-low"), result.map(MarketItem::coinId), ) } @@ -102,9 +103,9 @@ class MarketSearchViewModelSearchTest { resolveMarketItem = { MarketItem.fromMarket(it) }, ) - assertEquals(listOf("btc", "eth", "btc-cash"), result.map(MarketItem::coinId)) + assertEquals(listOf("btc", "btc-cash", "eth"), result.map(MarketItem::coinId)) assertEquals("Remote BTC", result.first().name) - assertEquals("Remote ETH", result[1].name) + assertEquals("Remote ETH", result.last().name) } @Test @@ -157,16 +158,22 @@ class MarketSearchViewModelSearchTest { assertEquals(0, remoteCalls) } - private fun market(coinId: String, marketCap: String = "1") = + private fun market( + coinId: String, + marketCap: String = "1", + name: String = coinId.uppercase(), + symbol: String = coinId.uppercase(), + totalVolume: String = "1", + ) = Market( coinId = coinId, - name = coinId.uppercase(), - symbol = coinId.uppercase(), + name = name, + symbol = symbol, iconUrl = "", currentPrice = "1", marketCap = marketCap, marketCapRank = "1", - totalVolume = "1", + totalVolume = totalVolume, high24h = "1", low24h = "1", priceChange24h = "0", From accdf65e2cdd3332c3ecbff48e4b8de6b4487d4b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sat, 5 Sep 2026 08:19:16 +0800 Subject: [PATCH 06/12] fix(market): restore and stabilize recent searches --- .../home/web3/market/MarketSearchFragment.kt | 7 ++---- .../ui/home/web3/market/MarketSearchPage.kt | 13 +++++------ .../home/web3/market/MarketSearchViewModel.kt | 22 +++++++++++++++---- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt index 47dc62c601..1f09eb5132 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt @@ -61,11 +61,8 @@ class MarketSearchFragment : BaseFragment() { } } - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) + override fun onStart() { + super.onStart() marketSearchViewModel.loadRecentSearches(requireContext().defaultSharedPreferences) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index 44e9e0fe5e..9a05ea608b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -419,12 +419,10 @@ private fun MarketRecentSearchChip( verticalAlignment = Alignment.CenterVertically, ) { MarketIcon(url = recentSearch.iconUrl.orEmpty(), size = 32.dp) - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(4.dp)) Column( - modifier = Modifier - .wrapContentWidth() - .height(32.dp), - verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.wrapContentWidth(), + verticalArrangement = Arrangement.spacedBy((-2).dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -433,7 +431,8 @@ private fun MarketRecentSearchChip( fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, - lineHeight = 14.sp, + lineHeight = 18.sp, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), modifier = Modifier.widthIn(min = 34.dp), ) if (recentSearch.type == RecentSearchType.PERPETUAL) { @@ -456,7 +455,7 @@ private fun MarketRecentSearchChip( text = formatSearchPercent(search.change), color = searchChangeColor(search.change, quoteColorReversed), fontSize = 13.sp, - lineHeight = 13.sp, + lineHeight = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index 9ce888793a..d48867f6c7 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.Constants.Account.PREF_MARKET_RECENT_SEARCH import one.mixin.android.Constants.Account.PREF_RECENT_SEARCH @@ -134,7 +135,6 @@ internal class MarketSearchViewModel viewModelScope.launch(Dispatchers.IO) { val searches = readRecentSearches(sp).addMarketRecentSearch(search) sp.putString(PREF_MARKET_RECENT_SEARCH, GsonHelper.customGson.toJson(searches)) - publishRecentSearches(searches) } } @@ -145,13 +145,27 @@ internal class MarketSearchViewModel } suspend fun findSpotMarket(coinId: String): MarketItem? = - tokenRepository.findMarketItemByCoinId(coinId) + withContext(Dispatchers.IO) { + try { + tokenRepository.checkMarketById(coinId) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } suspend fun findPerpetualMarket(marketId: String) = perpsMarketRepository.getOrRefreshMarket(marketId) private suspend fun publishRecentSearches(searches: List) { - _recentSearches.value = searches.map { MarketRecentSearch(it) } + val previousSearches = _recentSearches.value + _recentSearches.value = searches.map { search -> + val change = previousSearches.firstOrNull { + it.search.type == search.type && it.search.primaryKey == search.primaryKey + }?.change + MarketRecentSearch(search, change) + } val resolvedSearches = mutableListOf() for (search in searches) { resolvedSearches += resolveRecentSearch(search) @@ -165,7 +179,7 @@ internal class MarketSearchViewModel when (search.type) { RecentSearchType.MARKET -> search.primaryKey - ?.let { tokenRepository.findMarketItemByCoinId(it) } + ?.let { findSpotMarket(it) } ?.priceChangePercentage24H ?.toBigDecimalOrNull() RecentSearchType.PERPETUAL -> From d809b913bb049fabc28e3a583976adfdfa52c9c7 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 8 Sep 2026 15:44:46 +0800 Subject: [PATCH 07/12] fix(market): refine search item labels and layout --- .../ui/home/web3/market/MarketSearchPage.kt | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index 9a05ea608b..e3135bbeb5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -75,6 +76,8 @@ internal fun MarketSearchPage( onPerpetualMarketClick: (PerpsMarket) -> Unit, ) { val marketRecentSearches = recentSearches + val showRecentSearches = state.query.isBlank() && marketRecentSearches.isNotEmpty() + val showResultSections = state.hasQuery && state.selectedTab == MarketSearchTab.ALL val tabs = marketSearchTabs(state.query) val quoteColorReversed = androidx.compose.ui.platform.LocalContext.current.defaultSharedPreferences @@ -98,10 +101,12 @@ internal fun MarketSearchPage( onCancel = onCancel, ) LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .background(if (showResultSections) MixinAppTheme.colors.backgroundWindow else MixinAppTheme.colors.background), state = listState, ) { - if (state.query.isBlank() && marketRecentSearches.isNotEmpty()) { + if (showRecentSearches) { item(key = "recent_header") { Row( modifier = Modifier @@ -133,8 +138,8 @@ internal fun MarketSearchPage( modifier = Modifier .fillMaxWidth() .padding(start = 20.dp, end = 20.dp, bottom = 20.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + verticalArrangement = Arrangement.spacedBy(11.dp), ) { marketRecentSearches.forEach { search -> MarketRecentSearchChip( @@ -159,7 +164,8 @@ internal fun MarketSearchPage( Row( modifier = Modifier .fillMaxWidth() - .padding(start = 20.dp, top = 20.dp, end = 20.dp, bottom = 6.dp), + .background(MixinAppTheme.colors.background) + .padding(start = 20.dp, top = if (showRecentSearches) 20.dp else 12.dp, end = 20.dp, bottom = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { tabs.forEach { tab -> @@ -231,6 +237,7 @@ internal fun MarketSearchPage( .forEach { market -> SpotMarketSearchRow( market = market, + subtitle = market.name, quoteColorReversed = quoteColorReversed, onClick = { onSpotMarketClick(market) }, ) @@ -278,6 +285,7 @@ internal fun MarketSearchPage( ) { market -> SpotMarketSearchRow( market = market, + subtitle = market.name, quoteColorReversed = quoteColorReversed, onClick = { onSpotMarketClick(market) }, ) @@ -472,7 +480,11 @@ private fun SearchResultSection( onMore: () -> Unit, content: @Composable () -> Unit, ) { - Column { + Column( + modifier = Modifier + .background(MixinAppTheme.colors.background) + .padding(bottom = 12.dp), + ) { Row( modifier = Modifier .fillMaxWidth() @@ -506,11 +518,12 @@ private fun SpotMarketSearchRow( market: MarketItem, quoteColorReversed: Boolean, onClick: () -> Unit, + subtitle: String? = null, ) { MarketSearchRow( iconUrl = market.iconUrl, title = market.symbol, - volume = formatSearchSpotVolume(market.totalVolume), + subtitle = subtitle ?: stringResource(R.string.volume_label, formatSearchSpotVolume(market.totalVolume)), price = formatSearchSpotPrice(market.currentPrice), change = market.priceChangePercentage24H.toBigDecimalOrNull(), quoteColorReversed = quoteColorReversed, @@ -526,9 +539,9 @@ private fun PerpetualMarketSearchRow( ) { MarketSearchRow( iconUrl = market.iconUrl, - title = market.displaySymbol.ifBlank { market.tokenSymbol }, + title = market.tokenSymbol, badge = stringResource(R.string.Perp), - volume = formatSearchPerpetualVolume(market.volume), + subtitle = stringResource(R.string.volume_label, formatSearchPerpetualVolume(market.volume)), price = formatPerpsMarketListPrice(market.last), change = market.changePercentValue(), quoteColorReversed = quoteColorReversed, @@ -540,7 +553,7 @@ private fun PerpetualMarketSearchRow( private fun MarketSearchRow( iconUrl: String, title: String, - volume: String, + subtitle: String, price: String, change: BigDecimal?, quoteColorReversed: Boolean, @@ -551,12 +564,13 @@ private fun MarketSearchRow( Row( modifier = Modifier .fillMaxWidth() + .heightIn(min = 70.dp) .clickable(onClick = onClick) .padding(horizontal = 20.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - MarketIcon(url = iconUrl, size = 38.dp) - Spacer(modifier = Modifier.width(10.dp)) + MarketIcon(url = iconUrl, size = 42.dp) + Spacer(modifier = Modifier.width(16.dp)) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -565,8 +579,8 @@ private fun MarketSearchRow( Text( text = title, color = MixinAppTheme.colors.textPrimary, - fontSize = 14.sp, - lineHeight = 14.sp, + fontSize = 16.sp, + lineHeight = 18.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), @@ -589,24 +603,25 @@ private fun MarketSearchRow( } } Text( - text = stringResource(R.string.volume_label, volume), + text = subtitle, color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, - lineHeight = 12.sp, + fontSize = 14.sp, + lineHeight = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), ) } + Spacer(modifier = Modifier.width(8.dp)) Column( - modifier = Modifier.width(96.dp), + modifier = Modifier.widthIn(min = 96.dp), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = price, color = MixinAppTheme.colors.textPrimary, - fontSize = 14.sp, + fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.End, From 6b10387f3edfc06e7d48e9025cd7a6e2ad78fa94 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 8 Sep 2026 16:48:18 +0800 Subject: [PATCH 08/12] fix(market): simplify search ranking and tighten spacing --- .../home/web3/market/MarketSearchFragment.kt | 2 +- .../ui/home/web3/market/MarketSearchModels.kt | 6 +----- .../ui/home/web3/market/MarketSearchPage.kt | 20 +++++++++++++------ .../home/web3/market/MarketSearchViewModel.kt | 2 -- .../web3/market/MarketSearchModelsTest.kt | 5 ++--- .../market/MarketSearchViewModelSearchTest.kt | 4 ++-- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt index 1f09eb5132..ee80208074 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt @@ -110,7 +110,7 @@ class MarketSearchFragment : BaseFragment() { RecentSearch( type = RecentSearchType.PERPETUAL, iconUrl = market.iconUrl, - title = market.displaySymbol.ifBlank { market.tokenSymbol }, + title = market.tokenSymbol, subTitle = market.tokenSymbol, primaryKey = market.marketId, ), diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt index 485be3f533..d32977a165 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchModels.kt @@ -40,16 +40,12 @@ internal fun List.sortedForTrendingSearch(): List = ) internal fun List.sortedForMarketSearch( - query: String, symbol: (T) -> String, name: (T) -> String, volume: (T) -> String, ): List { - val normalizedQuery = query.trim() return sortedWith( - compareByDescending { symbol(it).equals(normalizedQuery, ignoreCase = true) } - .thenByDescending { name(it).equals(normalizedQuery, ignoreCase = true) } - .thenByDescending { volume(it).toBigDecimalOrNull() ?: BigDecimal.ZERO } + compareByDescending { volume(it).toBigDecimalOrNull() ?: BigDecimal.ZERO } .thenBy { symbol(it).lowercase(Locale.ROOT) } .thenBy { name(it).lowercase(Locale.ROOT) }, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index e3135bbeb5..be253c0447 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -165,7 +165,7 @@ internal fun MarketSearchPage( modifier = Modifier .fillMaxWidth() .background(MixinAppTheme.colors.background) - .padding(start = 20.dp, top = if (showRecentSearches) 20.dp else 12.dp, end = 20.dp, bottom = 6.dp), + .padding(start = 20.dp, top = if (showRecentSearches) 20.dp else 12.dp, end = 20.dp, bottom = if (showResultSections) 0.dp else 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { tabs.forEach { tab -> @@ -434,7 +434,7 @@ private fun MarketRecentSearchChip( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = recentSearch.title.orEmpty(), + text = if (recentSearch.type == RecentSearchType.PERPETUAL) recentSearch.subTitle.orEmpty() else recentSearch.title.orEmpty(), color = MixinAppTheme.colors.textPrimary, fontSize = 14.sp, maxLines = 1, @@ -488,13 +488,15 @@ private fun SearchResultSection( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), + .padding(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = title, color = MixinAppTheme.colors.textPrimary, fontSize = 14.sp, + lineHeight = 14.sp, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), ) if (hasMore) { Spacer(modifier = Modifier.weight(1f)) @@ -502,10 +504,12 @@ private fun SearchResultSection( text = stringResource(R.string.More), color = MixinAppTheme.colors.accent, fontSize = 14.sp, + lineHeight = 14.sp, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), modifier = Modifier .clip(CircleShape) .clickable(onClick = onMore) - .padding(horizontal = 4.dp, vertical = 4.dp), + .padding(horizontal = 4.dp), ) } } @@ -564,9 +568,9 @@ private fun MarketSearchRow( Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 70.dp) + .heightIn(min = 64.dp) .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 10.dp), + .padding(horizontal = 20.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { MarketIcon(url = iconUrl, size = 42.dp) @@ -622,6 +626,8 @@ private fun MarketSearchRow( text = price, color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, + lineHeight = 18.sp, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.End, @@ -630,6 +636,8 @@ private fun MarketSearchRow( text = formatSearchPercent(change), color = changeColor, fontSize = 14.sp, + lineHeight = 16.sp, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), maxLines = 1, textAlign = TextAlign.End, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt index d48867f6c7..b8f122b12d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModel.kt @@ -243,7 +243,6 @@ internal class MarketSearchViewModel private suspend fun searchPerpetualMarkets(query: String) = try { perpsMarketRepository.searchMarketsOnlineFirst(query).sortedForMarketSearch( - query = query, symbol = PerpsMarket::tokenSymbol, name = PerpsMarket::displaySymbol, volume = PerpsMarket::volume, @@ -281,7 +280,6 @@ internal suspend fun searchSpotMarketsOnlineFirst( return markets .map { market -> resolveMarketItem(market) } .sortedForMarketSearch( - query = normalizedQuery, symbol = MarketItem::symbol, name = MarketItem::name, volume = MarketItem::totalVolume, diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt index 2e57e702f7..2bbcaedeef 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt @@ -48,7 +48,7 @@ class MarketSearchModelsTest { } @Test - fun marketSearchRanksExactFieldsThenVolumeAndAlphabetically() { + fun marketSearchRanksByVolumeThenAlphabetically() { val markets = listOf( market("volume", volume = "100", tokenSymbol = "BTC-X"), @@ -59,10 +59,9 @@ class MarketSearchModelsTest { ) assertEquals( - listOf("symbol", "name", "volume", "alpha-a", "alpha-b"), + listOf("volume", "alpha-a", "alpha-b", "name", "symbol"), markets .sortedForMarketSearch( - query = "btc", symbol = PerpsMarket::tokenSymbol, name = PerpsMarket::displaySymbol, volume = PerpsMarket::volume, diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt index e7dd629732..ca45fc9df8 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt @@ -47,7 +47,7 @@ class MarketSearchViewModelSearchTest { } @Test - fun spotSearch_sortsByExactSymbolThenNameThenVolume() = + fun spotSearch_sortsByVolumeWithoutMatchPriority() = runBlocking { val initialLocal = listOf( @@ -75,7 +75,7 @@ class MarketSearchViewModelSearchTest { ) assertEquals( - listOf("symbol-exact", "name-exact", "volume-high", "volume-low"), + listOf("volume-high", "name-exact", "volume-low", "symbol-exact"), result.map(MarketItem::coinId), ) } From 1fddaf412e2b5791d92ae5e62f3f57b776a3808a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 8 Sep 2026 16:58:20 +0800 Subject: [PATCH 09/12] fix(market): adjust search row typography --- .../one/mixin/android/ui/home/web3/market/MarketSearchPage.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index be253c0447..9c2e1354b3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -610,7 +610,7 @@ private fun MarketSearchRow( text = subtitle, color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, - lineHeight = 16.sp, + lineHeight = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), @@ -625,7 +625,7 @@ private fun MarketSearchRow( Text( text = price, color = MixinAppTheme.colors.textPrimary, - fontSize = 16.sp, + fontSize = 14.sp, lineHeight = 18.sp, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), maxLines = 1, From d12ceb50ffa40045b13d182a303c12803cd6d92a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 8 Sep 2026 17:21:50 +0800 Subject: [PATCH 10/12] fix(market): align search loading and empty states --- .../ui/home/web3/market/MarketSearchPage.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index 9c2e1354b3..7e48983bbd 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt @@ -77,7 +77,8 @@ internal fun MarketSearchPage( ) { val marketRecentSearches = recentSearches val showRecentSearches = state.query.isBlank() && marketRecentSearches.isNotEmpty() - val showResultSections = state.hasQuery && state.selectedTab == MarketSearchTab.ALL + val showResultSections = state.hasQuery && !state.isSearching && state.selectedTab == MarketSearchTab.ALL && + (state.spotResults.isNotEmpty() || state.perpetualResults.isNotEmpty()) val tabs = marketSearchTabs(state.query) val quoteColorReversed = androidx.compose.ui.platform.LocalContext.current.defaultSharedPreferences @@ -103,7 +104,13 @@ internal fun MarketSearchPage( LazyColumn( modifier = Modifier .fillMaxSize() - .background(if (showResultSections) MixinAppTheme.colors.backgroundWindow else MixinAppTheme.colors.background), + .background( + if (showResultSections) { + MixinAppTheme.colors.backgroundWindow + } else { + MixinAppTheme.colors.background + }, + ), state = listState, ) { if (showRecentSearches) { @@ -183,7 +190,7 @@ internal fun MarketSearchPage( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 20.dp), + .padding(top = 36.dp, bottom = 20.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( @@ -441,7 +448,7 @@ private fun MarketRecentSearchChip( overflow = TextOverflow.Ellipsis, lineHeight = 18.sp, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), - modifier = Modifier.widthIn(min = 34.dp), + modifier = if (recentSearch.type == RecentSearchType.PERPETUAL) Modifier else Modifier.widthIn(min = 34.dp), ) if (recentSearch.type == RecentSearchType.PERPETUAL) { Spacer(modifier = Modifier.width(3.dp)) From ced23c5ff0c14e2c2ba69d5c05486812f362042b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 8 Sep 2026 20:42:02 +0800 Subject: [PATCH 11/12] fix(web3): align transaction amount display with status --- .../web3/details/AssetChangeCompose.kt | 18 ++++- .../details/Web3TransactionDetailState.kt | 33 ++++++++- .../web3/details/Web3TransactionFragment.kt | 25 +++---- .../web3/details/Web3TransactionHolder.kt | 70 +++++++------------ .../details/Web3TransactionDetailStateTest.kt | 52 +++++++++++++- 5 files changed, 129 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/one/mixin/android/web3/details/AssetChangeCompose.kt b/app/src/main/java/one/mixin/android/web3/details/AssetChangeCompose.kt index 31904cf5b4..109d9fce18 100644 --- a/app/src/main/java/one/mixin/android/web3/details/AssetChangeCompose.kt +++ b/app/src/main/java/one/mixin/android/web3/details/AssetChangeCompose.kt @@ -18,6 +18,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -25,6 +27,7 @@ import kotlinx.coroutines.launch import one.mixin.android.R import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.db.web3.vo.TransactionStatus import one.mixin.android.db.web3.vo.AssetChange import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.extension.numberFormat2 @@ -44,6 +47,7 @@ fun AssetChangeItem( isReceive: Boolean = false, isUnlimited: Boolean = false, isApproval: Boolean = false, + status: String? = null, ) { val amountValue = if (isUnlimited) { stringResource(R.string.unlimited).replaceFirstChar { it.uppercase() } @@ -60,8 +64,12 @@ fun AssetChangeItem( } else { balanceChangePresentation(amountValue, isReceive) } - val textColor = if (isApproval) { + val textColor = if (isApproval && status != null && status != TransactionStatus.SUCCESS.value) { + MixinAppTheme.colors.textPrimary + } else if (isApproval) { MixinAppTheme.colors.walletRed + } else if (status != null) { + Color(LocalContext.current.web3AmountColor(status, amountValue, isReceive)) } else { presentation.tone.toColor() } @@ -83,7 +91,7 @@ fun AssetChangeItem( Spacer(modifier = Modifier.width(4.dp)) Text( - text = if (isUnlimited) amountValue else "${presentation.amount} ", + text = if (isUnlimited) amountValue else "${if (status == null) presentation.amount else formatWeb3AmountWithSign(amountValue, isReceive)} ", fontSize = 14.sp, color = textColor ) @@ -110,7 +118,8 @@ fun AssetChangesList( senders: List, receivers: List, fetchToken: suspend (String) -> Web3TokenItem?, - approvals: List? = null + approvals: List? = null, + status: String, ) { val scope = rememberCoroutineScope() val assetIds = remember(senders, receivers, approvals) { @@ -138,6 +147,7 @@ fun AssetChangesList( approvals?.forEachIndexed { index, approval -> val token = tokens[approval.assetId] AssetChangeItem( + status = status, amount = approval.amount, symbol = token?.symbol ?: "", iconUrl = token?.iconUrl, @@ -158,6 +168,7 @@ fun AssetChangesList( receivers.forEachIndexed { index, receiver -> val token = tokens[receiver.assetId] AssetChangeItem( + status = status, amount = receiver.amount, symbol = token?.symbol ?: "", iconUrl = token?.iconUrl, @@ -176,6 +187,7 @@ fun AssetChangesList( senders.forEachIndexed { index, sender -> val token = tokens[sender.assetId] AssetChangeItem( + status = status, amount = sender.amount, symbol = token?.symbol ?: "", iconUrl = token?.iconUrl, diff --git a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionDetailState.kt b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionDetailState.kt index 2e902b8f50..9fa0295d80 100644 --- a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionDetailState.kt +++ b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionDetailState.kt @@ -1,17 +1,23 @@ package one.mixin.android.web3.details +import android.content.Context +import one.mixin.android.R import one.mixin.android.db.web3.vo.TransactionStatus import one.mixin.android.db.web3.vo.TransactionType +import one.mixin.android.db.web3.vo.Web3TransactionItem +import one.mixin.android.extension.colorAttr internal data class Web3TransactionDetailState( val status: String, val transactionType: String, + val amount: String? = null, ) { val amountTone: Web3TransactionAmountTone get() = when { - status == TransactionStatus.PENDING.value || + amount?.replace(",", "")?.toBigDecimalOrNull()?.signum() == 0 || + status == TransactionStatus.PENDING.value || status == TransactionStatus.NOT_FOUND.value || - status == TransactionStatus.FAILED.value -> Web3TransactionAmountTone.ASSIST + status == TransactionStatus.FAILED.value -> Web3TransactionAmountTone.MINOR transactionType == TransactionType.TRANSFER_OUT.value -> Web3TransactionAmountTone.OUTGOING transactionType == TransactionType.TRANSFER_IN.value -> Web3TransactionAmountTone.INCOMING else -> Web3TransactionAmountTone.PRIMARY @@ -21,8 +27,29 @@ internal data class Web3TransactionDetailState( } internal enum class Web3TransactionAmountTone { - ASSIST, + MINOR, OUTGOING, INCOMING, PRIMARY, } + +internal fun formatWeb3AmountWithSign(amount: String, positive: Boolean): String { + val magnitude = amount.removePrefix("+").removePrefix("-") + if (magnitude.replace(",", "").toBigDecimalOrNull()?.signum() == 0) return magnitude + return if (positive) "+$magnitude" else "-$magnitude" +} + +internal fun Context.web3AmountColor(status: String, amount: String, isReceive: Boolean): Int = + when (Web3TransactionDetailState(status, if (isReceive) TransactionType.TRANSFER_IN.value else TransactionType.TRANSFER_OUT.value, amount).amountTone) { + Web3TransactionAmountTone.MINOR -> colorAttr(R.attr.text_minor) + Web3TransactionAmountTone.OUTGOING -> getColor(R.color.wallet_pink) + Web3TransactionAmountTone.INCOMING -> getColor(R.color.wallet_green) + Web3TransactionAmountTone.PRIMARY -> colorAttr(R.attr.text_primary) + } + +internal fun Web3TransactionItem.hasDisplayAssetChanges(): Boolean = when (transactionType) { + TransactionType.TRANSFER_IN.value -> receivers.isNotEmpty() + TransactionType.TRANSFER_OUT.value -> senders.isNotEmpty() + TransactionType.APPROVAL.value -> !approvals.isNullOrEmpty() + else -> receivers.isNotEmpty() || senders.isNotEmpty() +} diff --git a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt index c5a56b8af2..d04a156677 100644 --- a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt @@ -130,20 +130,12 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) private var refreshJob: Job? = null lateinit var rpc: Rpc - private fun formatAmountWithSign(amount: String, positive: Boolean): String { - return if (positive) { - if (amount.startsWith("+")) amount else "+$amount" - } else { - if (amount.startsWith("-")) amount else "-$amount" - } - } - private fun bindMainValue(state: Web3TransactionDetailState) { - val amountColor = when (state.amountTone) { - Web3TransactionAmountTone.ASSIST -> requireContext().colorFromAttribute(R.attr.text_assist) - Web3TransactionAmountTone.OUTGOING -> requireContext().getColor(R.color.wallet_pink) - Web3TransactionAmountTone.INCOMING -> requireContext().getColor(R.color.wallet_green) - Web3TransactionAmountTone.PRIMARY -> requireContext().colorFromAttribute(R.attr.text_primary) + val amountColor = requireContext().web3AmountColor(state.status, transaction.getMainAmount(), transaction.transactionType == TransactionType.TRANSFER_IN.value) + binding.valueTv.isVisible = when (transaction.transactionType) { + TransactionType.TRANSFER_IN.value, TransactionType.TRANSFER_OUT.value -> transaction.hasDisplayAssetChanges() + TransactionType.UNKNOWN.value -> false + else -> true } val symbolColor = requireContext().colorFromAttribute(R.attr.text_primary) val mainAmount = transaction.getFormattedAmount() @@ -180,7 +172,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } else { buildAmountSymbol( requireContext(), - formatAmountWithSign(mainAmount, transaction.transactionType == TransactionType.TRANSFER_IN.value), + formatWeb3AmountWithSign(mainAmount, transaction.transactionType == TransactionType.TRANSFER_IN.value), when (transaction.transactionType) { TransactionType.TRANSFER_OUT.value -> transaction.sendAssetSymbol ?: "" TransactionType.APPROVAL.value -> transaction.sendAssetSymbol ?: "" @@ -455,7 +447,6 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) toLl.isVisible = false } currentStatus == TransactionStatus.FAILED.value -> { - valueTv.isVisible = false fromLl.isVisible = false toLl.isVisible = false } @@ -519,6 +510,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) assetChangesContainer.setContent { AssetChangesList( + status = transactionDetailState.value.status, senders = transaction.senders, receivers = transaction.receivers, fetchToken = { assetId -> @@ -527,10 +519,11 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) approvals = transaction.approvals, ) } - } else if (transaction.transactionType == TransactionType.SWAP.value || (transaction.transactionType == TransactionType.TRANSFER_OUT.value && transaction.senders.size > 1) || (transaction.transactionType == TransactionType.TRANSFER_IN.value && transaction.receivers.size > 1)) { + } else if ((transaction.transactionType == TransactionType.UNKNOWN.value && transaction.hasDisplayAssetChanges()) || transaction.transactionType == TransactionType.SWAP.value || (transaction.transactionType == TransactionType.TRANSFER_OUT.value && transaction.senders.size > 1) || (transaction.transactionType == TransactionType.TRANSFER_IN.value && transaction.receivers.size > 1)) { assetChangesLl.visibility = View.VISIBLE assetChangesContainer.setContent { AssetChangesList( + status = transactionDetailState.value.status, senders = if (transaction.transactionType == TransactionType.TRANSFER_IN.value) emptyList() else transaction.senders, receivers = if (transaction.transactionType == TransactionType.TRANSFER_OUT.value) emptyList() else transaction.receivers, fetchToken = { assetId -> diff --git a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionHolder.kt b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionHolder.kt index ff7d4036e3..410332faa1 100644 --- a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionHolder.kt +++ b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionHolder.kt @@ -19,8 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.colorResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow @@ -40,7 +38,6 @@ import one.mixin.android.db.web3.vo.Web3TransactionItem import one.mixin.android.extension.colorAttr import one.mixin.android.extension.formatPublicKey import one.mixin.android.extension.numberFormat8 -import one.mixin.android.extension.textColorResource import one.mixin.android.ui.home.web3.StakeAccountSummary import one.mixin.android.extension.dp as dip @@ -65,16 +62,6 @@ class Web3TransactionHolder( } } - fun formatAmountWithSign(amount: String?, positive: Boolean): String { - if (amount.isNullOrEmpty()) return "N/A" - val formattedAmount = amount.numberFormat8() - return if (positive) { - if (formattedAmount.startsWith("+")) formattedAmount else "+$formattedAmount" - } else { - if (formattedAmount.startsWith("-")) formattedAmount else "-$formattedAmount" - } - } - @SuppressLint("SetTextI18s") fun bind(transaction: Web3TransactionItem) { binding.apply { @@ -83,21 +70,18 @@ class Web3TransactionHolder( icSpam.isVisible = transaction.isNotVerified() val amount = transaction.getFormattedAmount() when { - transaction.status == TransactionStatus.PENDING.value || transaction.status == TransactionStatus.NOT_FOUND.value -> { + !transaction.hasDisplayAssetChanges() && transaction.transactionType != TransactionType.APPROVAL.value -> { value.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) amountAnimator.displayedChild = 0 value.setTextColor(root.context.colorAttr(R.attr.text_assist)) value.text = "" symbolTv.text = - itemView.context.getString(if (transaction.status == TransactionStatus.NOT_FOUND.value) R.string.Expired else R.string.Pending) - avatar.loadUrl(transaction) - } - transaction.transactionType == TransactionType.UNKNOWN.value -> { - value.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) - amountAnimator.displayedChild = 0 - value.setTextColor(root.context.colorAttr(R.attr.text_assist)) - value.text = "" - symbolTv.text = itemView.context.getString(R.string.Unknown) + itemView.context.getString(when (transaction.status) { + TransactionStatus.PENDING.value -> R.string.Pending + TransactionStatus.NOT_FOUND.value -> R.string.Expired + TransactionStatus.FAILED.value -> R.string.Failed + else -> R.string.Unknown + }) avatar.loadUrl(transaction) } transaction.transactionType == TransactionType.TRANSFER_IN.value -> { @@ -106,12 +90,12 @@ class Web3TransactionHolder( amountAnimator.displayedChild = 1 val assetChanges = transaction.receivers.take(3) binding.doubleLineComposeView.setContent { - AmountList(assetChanges = assetChanges, senders = transaction.senders) + AmountList(assetChanges = assetChanges, senders = transaction.senders, status = transaction.status) } } else { amountAnimator.displayedChild = 0 - value.textColorResource = R.color.wallet_green - value.text = formatAmountWithSign(amount, true) + value.setTextColor(root.context.web3AmountColor(transaction.status, amount, true)) + value.text = formatWeb3AmountWithSign(amount.numberFormat8(), true) symbolTv.text = getFormattedSymbol(transaction.receiveAssetSymbol) ?: "" } avatar.loadUrl(transaction) @@ -122,28 +106,28 @@ class Web3TransactionHolder( amountAnimator.displayedChild = 1 val assetChanges = (transaction.senders).take(3) binding.doubleLineComposeView.setContent { - AmountList(assetChanges = assetChanges, senders = transaction.senders) + AmountList(assetChanges = assetChanges, senders = transaction.senders, status = transaction.status) } } else { amountAnimator.displayedChild = 0 - value.textColorResource = R.color.wallet_pink - value.text = formatAmountWithSign(amount, false) + value.setTextColor(root.context.web3AmountColor(transaction.status, amount, false)) + value.text = formatWeb3AmountWithSign(amount.numberFormat8(), false) symbolTv.text = getFormattedSymbol(transaction.sendAssetSymbol) ?: "" } avatar.loadUrl(transaction) } - transaction.transactionType == TransactionType.SWAP.value -> { + transaction.transactionType == TransactionType.SWAP.value || transaction.transactionType == TransactionType.UNKNOWN.value -> { value.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) - if (transaction.senders.isNotEmpty()) { + if (transaction.senders.isNotEmpty() || transaction.receivers.size > 1 || transaction.transactionType == TransactionType.UNKNOWN.value) { amountAnimator.displayedChild = 1 val assetChanges = (transaction.receivers + transaction.senders).take(3) binding.doubleLineComposeView.setContent { - AmountList(assetChanges = assetChanges, senders = transaction.senders) + AmountList(assetChanges = assetChanges, senders = transaction.senders, status = transaction.status) } } else { amountAnimator.displayedChild = 0 - value.textColorResource = R.color.wallet_green - value.text = formatAmountWithSign(amount, true) + value.setTextColor(root.context.web3AmountColor(transaction.status, amount, true)) + value.text = formatWeb3AmountWithSign(amount.numberFormat8(), true) symbolTv.text = getFormattedSymbol(transaction.receiveAssetSymbol) ?: "" } avatar.loadUrl(transaction) @@ -153,22 +137,20 @@ class Web3TransactionHolder( value.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) avatar.loadUrl(transaction) + value.setTextColor(if (transaction.status == TransactionStatus.SUCCESS.value) root.context.getColor(R.color.wallet_pink) else root.context.colorAttr(R.attr.text_primary)) val approvals = transaction.approvals if (approvals != null && approvals.isNotEmpty()) { val approvalAssetChange = approvals[0] val isUnlimited = approvalAssetChange.type == "unlimited" if (isUnlimited) { - value.textColorResource = R.color.wallet_pink value.text = itemView.context.getString(R.string.unlimited) symbolTv.text = getFormattedSymbol(transaction.sendAssetSymbol) ?: "" } else { - value.textColorResource = R.color.wallet_pink value.text = itemView.context.getString(R.string.Approved) symbolTv.text = "${approvalAssetChange.amount} ${getFormattedSymbol(transaction.sendAssetSymbol) ?: ""}" } } else { - value.textColorResource = R.color.wallet_pink value.text = itemView.context.getString(R.string.Approved) symbolTv.text = getFormattedSymbol(transaction.sendAssetSymbol) ?: "" } @@ -224,20 +206,20 @@ class Web3HeaderHolder(val binding: ItemWeb3TokenHeaderBinding) : RecyclerView.V fun AmountList( assetChanges: List, senders: List, + status: String, ) { - val holder = LocalView.current.tag as? Web3TransactionHolder Column( horizontalAlignment = Alignment.End, modifier = Modifier.wrapContentWidth() ) { assetChanges.forEachIndexed { index, assetChange -> val isSender = senders.contains(assetChange) - val amount = holder?.formatAmountWithSign(assetChange.amount, !isSender) ?: assetChange.amount AmountRow( - amount = amount, + amount = assetChange.amount, symbol = assetChange.symbol ?: "", - isSender = isSender + isSender = isSender, + status = status, ) if (index < assetChanges.size - 1) { Spacer(modifier = Modifier.height(2.dp)) @@ -247,15 +229,15 @@ fun AmountList( } @Composable -fun AmountRow(amount: String, symbol: String, isSender: Boolean) { +fun AmountRow(amount: String, symbol: String, isSender: Boolean, status: String) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, modifier = Modifier.wrapContentWidth() ) { Text( - text = "${if (isSender) "-" else "+"}${amount.numberFormat8()}", - color = colorResource(id = if (isSender) R.color.wallet_pink else R.color.wallet_green), + text = formatWeb3AmountWithSign(amount.numberFormat8(), !isSender), + color = Color(LocalContext.current.web3AmountColor(status, amount, !isSender)), fontSize = 16.sp, fontFamily = FontFamily(Font(R.font.mixin_font)), maxLines = 1, diff --git a/app/src/test/java/one/mixin/android/web3/details/Web3TransactionDetailStateTest.kt b/app/src/test/java/one/mixin/android/web3/details/Web3TransactionDetailStateTest.kt index fdec946bab..a6898b6c3b 100644 --- a/app/src/test/java/one/mixin/android/web3/details/Web3TransactionDetailStateTest.kt +++ b/app/src/test/java/one/mixin/android/web3/details/Web3TransactionDetailStateTest.kt @@ -1,9 +1,13 @@ package one.mixin.android.web3.details -import one.mixin.android.db.web3.vo.TransactionStatus -import one.mixin.android.db.web3.vo.TransactionType import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import one.mixin.android.db.web3.vo.AssetChange +import one.mixin.android.db.web3.vo.TransactionStatus +import one.mixin.android.db.web3.vo.TransactionType +import one.mixin.android.db.web3.vo.Web3TransactionItem class Web3TransactionDetailStateTest { @Test @@ -13,7 +17,7 @@ class Web3TransactionDetailStateTest { transactionType = TransactionType.TRANSFER_IN.value, ) - assertEquals(Web3TransactionAmountTone.ASSIST, pendingState.amountTone) + assertEquals(Web3TransactionAmountTone.MINOR, pendingState.amountTone) val refreshedState = pendingState.withStatus(TransactionStatus.SUCCESS.value) @@ -32,4 +36,46 @@ class Web3TransactionDetailStateTest { assertEquals(Web3TransactionAmountTone.OUTGOING, refreshedState.amountTone) } + @Test + fun unavailableStatusesKeepBothDirectionsNeutral() { + for (status in listOf(TransactionStatus.PENDING, TransactionStatus.FAILED, TransactionStatus.NOT_FOUND)) { + for (type in listOf(TransactionType.TRANSFER_IN, TransactionType.TRANSFER_OUT)) { + assertEquals(Web3TransactionAmountTone.MINOR, Web3TransactionDetailState(status.value, type.value, "12").amountTone) + } + } + } + + @Test + fun zeroIsNeutralAndUnsignedWhileDirectionsNormalizeExistingSigns() { + for (amount in listOf("0", "-0.00", "+0.00")) { + assertEquals(Web3TransactionAmountTone.MINOR, Web3TransactionDetailState(TransactionStatus.SUCCESS.value, TransactionType.TRANSFER_IN.value, amount).amountTone) + assertEquals(amount.removePrefix("+").removePrefix("-"), formatWeb3AmountWithSign(amount, true)) + assertEquals(amount.removePrefix("+").removePrefix("-"), formatWeb3AmountWithSign(amount, false)) + } + assertEquals("+1,234.5", formatWeb3AmountWithSign("-1,234.5", true)) + assertEquals("-12", formatWeb3AmountWithSign("+12", false)) + } + @Test + fun missingAssetChangesAreDistinctFromAnActualZeroAmount() { + val transaction = Web3TransactionItem( + transactionHash = "hash", + transactionType = TransactionType.TRANSFER_IN.value, + status = TransactionStatus.FAILED.value, + blockNumber = 0, + chainId = "chain", + address = "address", + fee = "0", + senders = emptyList(), + receivers = emptyList(), + transactionAt = "2026-09-08T00:00:00Z", + updatedAt = "2026-09-08T00:00:00Z", + level = 0, + ) + val zero = AssetChange(assetId = "asset", amount = "0") + assertFalse(transaction.hasDisplayAssetChanges()) + assertTrue(transaction.copy(receivers = listOf(zero)).hasDisplayAssetChanges()) + assertFalse(transaction.copy(senders = listOf(zero)).hasDisplayAssetChanges()) + assertTrue(transaction.copy(transactionType = TransactionType.TRANSFER_OUT.value, senders = listOf(zero)).hasDisplayAssetChanges()) + assertTrue(transaction.copy(transactionType = TransactionType.UNKNOWN.value, receivers = listOf(zero)).hasDisplayAssetChanges()) + } } From 0b4fdc3271f891baace336ff88efc9c70a152fca Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 9 Sep 2026 14:04:03 +0800 Subject: [PATCH 12/12] fix(db): remove duplicate and unused query columns --- app/src/main/java/one/mixin/android/db/MarketCategoryDao.kt | 3 +++ app/src/main/java/one/mixin/android/db/SafeSnapshotDao.kt | 2 +- app/src/main/java/one/mixin/android/db/TokenDao.kt | 6 +++--- app/src/main/java/one/mixin/android/db/UserDao.kt | 4 ++-- .../java/one/mixin/android/db/web3/Web3TransactionDao.kt | 4 ++-- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/MarketCategoryDao.kt b/app/src/main/java/one/mixin/android/db/MarketCategoryDao.kt index 5de423fee1..d9902c21cb 100644 --- a/app/src/main/java/one/mixin/android/db/MarketCategoryDao.kt +++ b/app/src/main/java/one/mixin/android/db/MarketCategoryDao.kt @@ -2,6 +2,7 @@ package one.mixin.android.db import androidx.room3.Dao import androidx.room3.Query +import androidx.room3.RewriteQueriesToDropUnusedColumns import androidx.room3.Transaction import kotlinx.coroutines.flow.Flow import one.mixin.android.vo.market.MarketCategoryRelation @@ -36,6 +37,7 @@ interface MarketCategoryDao : BaseDao { } } + @RewriteQueriesToDropUnusedColumns @Query( """ SELECT m.*, mf.is_favored @@ -48,6 +50,7 @@ interface MarketCategoryDao : BaseDao { ) fun observeMarketsByCategory(category: Int): Flow> + @RewriteQueriesToDropUnusedColumns @Query( """ SELECT m.*, mf.is_favored diff --git a/app/src/main/java/one/mixin/android/db/SafeSnapshotDao.kt b/app/src/main/java/one/mixin/android/db/SafeSnapshotDao.kt index 11967decdc..cd7bf812ef 100644 --- a/app/src/main/java/one/mixin/android/db/SafeSnapshotDao.kt +++ b/app/src/main/java/one/mixin/android/db/SafeSnapshotDao.kt @@ -26,7 +26,7 @@ interface SafeSnapshotDao : BaseDao { SELECT s.snapshot_id, s.type, s.asset_id, s.amount, s.created_at, s.opponent_id, s.trace_id, s.memo, s.confirmations, s.transaction_hash, s.opening_balance, s.closing_balance, s.deposit AS deposit, s.withdrawal AS withdrawal, u.avatar_url, u.full_name AS opponent_ful_name, t.symbol AS asset_symbol, t.confirmations AS asset_confirmations, - i.inscription_hash, i.collection_hash, i.inscription_hash, ic.name, i.sequence, i.content_type, i.content_url, ic.icon_url + i.inscription_hash, i.collection_hash, ic.name, i.sequence, i.content_type, i.content_url, ic.icon_url FROM safe_snapshots s LEFT JOIN users u ON u.user_id = s.opponent_id LEFT JOIN tokens t ON t.asset_id = s.asset_id diff --git a/app/src/main/java/one/mixin/android/db/TokenDao.kt b/app/src/main/java/one/mixin/android/db/TokenDao.kt index ce05e924d8..ffe4bcbb33 100644 --- a/app/src/main/java/one/mixin/android/db/TokenDao.kt +++ b/app/src/main/java/one/mixin/android/db/TokenDao.kt @@ -45,7 +45,7 @@ interface TokenDao : BaseDao { @Query("$PREFIX_ASSET_ITEM") fun assetFlow(): Flow> - @Query("SELECT * FROM tokens a1 LEFT JOIN tokens_extra ae ON ae.asset_id = a1.asset_id $POSTFIX") + @Query("SELECT a1.* FROM tokens a1 LEFT JOIN tokens_extra ae ON ae.asset_id = a1.asset_id $POSTFIX") fun assets(): LiveData> @Query("SELECT a1.* FROM tokens a1 LEFT JOIN tokens_extra ae ON ae.asset_id = a1.asset_id WHERE balance > 0 $POSTFIX") @@ -54,10 +54,10 @@ interface TokenDao : BaseDao { @Query("SELECT a1.* FROM tokens a1 LEFT JOIN tokens_extra ae ON ae.asset_id = a1.asset_id WHERE balance > 0 $POSTFIX") suspend fun simpleAssetsWithBalance(): List - @Query("SELECT a1.asset_id, a1.chain_id, ae.balance, a1.symbol, a1.name, a1.icon_url, ae.balance FROM tokens a1 LEFT JOIN tokens_extra ae ON ae.asset_id = a1.asset_id WHERE balance > 0 $POSTFIX") + @Query("SELECT a1.asset_id, a1.chain_id, ae.balance, a1.symbol, a1.name, a1.icon_url FROM tokens a1 LEFT JOIN tokens_extra ae ON ae.asset_id = a1.asset_id WHERE balance > 0 $POSTFIX") suspend fun tokenEntry(): List - @Query("SELECT a1.asset_id, a1.chain_id, ae.balance, a1.symbol, a1.name, a1.icon_url, ae.balance FROM tokens a1 LEFT JOIN tokens_extra ae ON ae.asset_id = a1.asset_id WHERE a1.asset_id IN (:ids)") + @Query("SELECT a1.asset_id, a1.chain_id, ae.balance, a1.symbol, a1.name, a1.icon_url FROM tokens a1 LEFT JOIN tokens_extra ae ON ae.asset_id = a1.asset_id WHERE a1.asset_id IN (:ids)") suspend fun tokenEntry(ids: Array): List @Query("SELECT asset_id FROM tokens WHERE kernel_asset_id = :asset") diff --git a/app/src/main/java/one/mixin/android/db/UserDao.kt b/app/src/main/java/one/mixin/android/db/UserDao.kt index d6b6959eb2..a955cf900b 100644 --- a/app/src/main/java/one/mixin/android/db/UserDao.kt +++ b/app/src/main/java/one/mixin/android/db/UserDao.kt @@ -223,7 +223,7 @@ interface UserDao : BaseDao { @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query( - """SELECT * FROM users u INNER JOIN participants p ON p.user_id = u.user_id + """SELECT u.*, p.role FROM users u INNER JOIN participants p ON p.user_id = u.user_id WHERE p.conversation_id = :conversationId AND u.user_id IN (:userIds) """, ) @@ -234,7 +234,7 @@ interface UserDao : BaseDao { @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query( - """SELECT * FROM users u INNER JOIN participants p ON p.user_id = u.user_id + """SELECT u.*, p.role FROM users u INNER JOIN participants p ON p.user_id = u.user_id WHERE p.conversation_id = :conversationId AND u.user_id = :userId """, ) diff --git a/app/src/main/java/one/mixin/android/db/web3/Web3TransactionDao.kt b/app/src/main/java/one/mixin/android/db/web3/Web3TransactionDao.kt index 1f0a153fd2..8d144a499f 100644 --- a/app/src/main/java/one/mixin/android/db/web3/Web3TransactionDao.kt +++ b/app/src/main/java/one/mixin/android/db/web3/Web3TransactionDao.kt @@ -58,7 +58,7 @@ interface Web3TransactionDao : BaseDao { @RawQuery(observedEntities = [Web3Transaction::class]) fun allTransactions(query: RoomRawQuery): PagingSource - @Query("SELECT DISTINCT transaction_hash, * FROM transactions WHERE transaction_hash = :hash AND chain_id = :chainId LIMIT 1") + @Query("SELECT DISTINCT * FROM transactions WHERE transaction_hash = :hash AND chain_id = :chainId LIMIT 1") suspend fun getLatestTransaction(hash: String, chainId: String): Web3Transaction? @Query("DELETE FROM transactions WHERE status = 'pending' AND transaction_hash = :hash AND chain_id = :chainId") @@ -73,7 +73,7 @@ interface Web3TransactionDao : BaseDao { @Query("SELECT COUNT(*) FROM transactions WHERE status = 'pending' AND address in (SELECT destination FROM addresses WHERE wallet_id = :walletId)") fun getPendingTransactionCount(walletId: String): LiveData - @Query("SELECT DISTINCT transaction_hash, * FROM transactions WHERE status = 'pending' AND address in (SELECT destination FROM addresses WHERE wallet_id = :walletId)") + @Query("SELECT DISTINCT * FROM transactions WHERE status = 'pending' AND address in (SELECT destination FROM addresses WHERE wallet_id = :walletId)") suspend fun getPendingTransactions(walletId: String): List @Query(""" SELECT DISTINCT w.transaction_hash, w.transaction_type, w.status, w.block_number, w.chain_id, w.address, w.fee, w.sponsor_fee_asset_id, w.sponsor_fee_amount, w.senders, w.receivers, w.approvals, w.send_asset_id, w.receive_asset_id, w.transaction_at, w.updated_at, w.level,