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, 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 991b622299..5747aadb6c 100644 --- a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt @@ -1,5 +1,6 @@ package one.mixin.android.repository +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.withRoomTransaction 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/MarketSearchFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchFragment.kt index 47dc62c601..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 @@ -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) } @@ -113,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 7882c64c0d..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 @@ -39,6 +39,18 @@ internal fun List.sortedForTrendingSearch(): List = .thenBy { it.marketId }, ) +internal fun List.sortedForMarketSearch( + symbol: (T) -> String, + name: (T) -> String, + volume: (T) -> String, +): List { + return sortedWith( + compareByDescending { 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/MarketSearchPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketSearchPage.kt index c1e901e622..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 @@ -14,9 +14,11 @@ 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 +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -74,6 +76,9 @@ internal fun MarketSearchPage( onPerpetualMarketClick: (PerpsMarket) -> Unit, ) { val marketRecentSearches = recentSearches + val showRecentSearches = state.query.isBlank() && marketRecentSearches.isNotEmpty() + 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 @@ -97,10 +102,18 @@ 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 @@ -113,6 +126,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,9 +144,9 @@ internal fun MarketSearchPage( FlowRow( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(start = 20.dp, end = 20.dp, bottom = 20.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + verticalArrangement = Arrangement.spacedBy(11.dp), ) { marketRecentSearches.forEach { search -> MarketRecentSearchChip( @@ -157,7 +171,8 @@ internal fun MarketSearchPage( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), + .background(MixinAppTheme.colors.background) + .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 -> @@ -175,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( @@ -229,6 +244,7 @@ internal fun MarketSearchPage( .forEach { market -> SpotMarketSearchRow( market = market, + subtitle = market.name, quoteColorReversed = quoteColorReversed, onClick = { onSpotMarketClick(market) }, ) @@ -276,6 +292,7 @@ internal fun MarketSearchPage( ) { market -> SpotMarketSearchRow( market = market, + subtitle = market.name, quoteColorReversed = quoteColorReversed, onClick = { onSpotMarketClick(market) }, ) @@ -417,18 +434,21 @@ 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(), - verticalArrangement = Arrangement.spacedBy(3.dp), + verticalArrangement = Arrangement.spacedBy((-2).dp), ) { 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, overflow = TextOverflow.Ellipsis, + lineHeight = 18.sp, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), + modifier = if (recentSearch.type == RecentSearchType.PERPETUAL) Modifier else Modifier.widthIn(min = 34.dp), ) if (recentSearch.type == RecentSearchType.PERPETUAL) { Spacer(modifier = Modifier.width(3.dp)) @@ -449,11 +469,12 @@ private fun MarketRecentSearchChip( Text( text = formatSearchPercent(search.change), color = searchChangeColor(search.change, quoteColorReversed), - fontSize = 12.sp, - lineHeight = 12.sp, + fontSize = 13.sp, + lineHeight = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), + modifier = Modifier.widthIn(min = 34.dp), ) } } @@ -466,17 +487,23 @@ private fun SearchResultSection( onMore: () -> Unit, content: @Composable () -> Unit, ) { - Column { + Column( + modifier = Modifier + .background(MixinAppTheme.colors.background) + .padding(bottom = 12.dp), + ) { 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)) @@ -484,10 +511,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), ) } } @@ -500,11 +529,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, @@ -520,10 +550,10 @@ 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), - price = "$${market.last}", + subtitle = stringResource(R.string.volume_label, formatSearchPerpetualVolume(market.volume)), + price = formatPerpsMarketListPrice(market.last), change = market.changePercentValue(), quoteColorReversed = quoteColorReversed, onClick = onClick, @@ -534,7 +564,7 @@ private fun PerpetualMarketSearchRow( private fun MarketSearchRow( iconUrl: String, title: String, - volume: String, + subtitle: String, price: String, change: BigDecimal?, quoteColorReversed: Boolean, @@ -545,12 +575,13 @@ private fun MarketSearchRow( Row( modifier = Modifier .fillMaxWidth() + .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 = 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), @@ -559,8 +590,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)), @@ -583,17 +614,18 @@ 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 = 15.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), ) { @@ -601,6 +633,8 @@ private fun MarketSearchRow( text = price, color = MixinAppTheme.colors.textPrimary, fontSize = 14.sp, + lineHeight = 18.sp, + style = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.End, @@ -609,6 +643,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 fa7490de13..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 @@ -17,9 +17,12 @@ 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 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 +30,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 @@ -131,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) } } @@ -142,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) @@ -162,7 +179,7 @@ internal class MarketSearchViewModel when (search.type) { RecentSearchType.MARKET -> search.primaryKey - ?.let { tokenRepository.findMarketItemByCoinId(it) } + ?.let { findSpotMarket(it) } ?.priceChangePercentage24H ?.toBigDecimalOrNull() RecentSearchType.PERPETUAL -> @@ -205,15 +222,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 +242,46 @@ internal class MarketSearchViewModel private suspend fun searchPerpetualMarkets(query: String) = try { - initialPerpetualSyncJob.join() - perpsMarketRepository.searchMarkets(query) + perpsMarketRepository.searchMarketsOnlineFirst(query).sortedForMarketSearch( + symbol = PerpsMarket::tokenSymbol, + name = PerpsMarket::displaySymbol, + volume = PerpsMarket::volume, + ) } 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) } + .sortedForMarketSearch( + symbol = MarketItem::symbol, + name = MarketItem::name, + volume = MarketItem::totalVolume, + ) +} 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/db/perps/PerpsMarketDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt index ae992defef..44c2429cfb 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/MarketSearchModelsTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchModelsTest.kt index 220e4eb684..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 @@ -47,6 +47,29 @@ class MarketSearchModelsTest { ) } + @Test + fun marketSearchRanksByVolumeThenAlphabetically() { + 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("volume", "alpha-a", "alpha-b", "name", "symbol"), + markets + .sortedForMarketSearch( + symbol = PerpsMarket::tokenSymbol, + name = PerpsMarket::displaySymbol, + volume = PerpsMarket::volume, + ) + .map(PerpsMarket::marketId), + ) + } + @Test fun recentSearchesKeepOnlySpotAndPerpetualMarkets() { val searches = @@ -88,11 +111,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 new file mode 100644 index 0000000000..ca45fc9df8 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketSearchViewModelSearchTest.kt @@ -0,0 +1,202 @@ +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-usdc", "btc-usdt"), result.map(MarketItem::coinId)) + } + + @Test + fun spotSearch_sortsByVolumeWithoutMatchPriority() = + runBlocking { + val initialLocal = + listOf( + market("volume-low", symbol = "BTC-X", totalVolume = "1"), + market("name-exact", symbol = "AAA", name = "BTC", totalVolume = "1"), + ) + val refreshedLocal = + listOf( + 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 + + val result = + searchSpotMarketsOnlineFirst( + query = "btc", + searchLocalMarkets = { + searchCalls += 1 + if (searchCalls == 1) initialLocal else refreshedLocal + }, + refreshOnlineMarkets = {}, + resolveMarketItem = { MarketItem.fromMarket(it) }, + ) + + assertEquals( + listOf("volume-high", "name-exact", "volume-low", "symbol-exact"), + 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", "btc-cash", "eth"), result.map(MarketItem::coinId)) + assertEquals("Remote BTC", result.first().name) + assertEquals("Remote ETH", result.last().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, + marketCap: String = "1", + name: String = coinId.uppercase(), + symbol: String = coinId.uppercase(), + totalVolume: String = "1", + ) = + Market( + coinId = coinId, + name = name, + symbol = symbol, + iconUrl = "", + currentPrice = "1", + marketCap = marketCap, + marketCapRank = "1", + totalVolume = totalVolume, + 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, + ) +} 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()) + } }