From 4bddd60ef2c527b0f0d70c90dc90a2ee34bb5114 Mon Sep 17 00:00:00 2001 From: gemdev1111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:17:26 +0300 Subject: [PATCH 1/7] Resolve current wallet inside UpdateRecentAsset Make UpdateRecentAsset symmetric with GetRecentAssets and ClearRecentAssets, which already resolve the active wallet internally. The coordinator now reads the session wallet itself instead of taking a walletId, so callers (and the perpetuals view model) no longer have to thread it through. --- .../asset_select/UpdateRecentAssetImpl.kt | 11 ++++++++--- .../android/data/coordinators/di/AssetSelectModule.kt | 3 ++- .../viewmodels/BaseAssetSelectViewModel.kt | 3 +-- .../asset_select/coordinators/UpdateRecentAsset.kt | 3 +-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset_select/UpdateRecentAssetImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset_select/UpdateRecentAssetImpl.kt index 586eea5a53..cc352f480e 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset_select/UpdateRecentAssetImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset_select/UpdateRecentAssetImpl.kt @@ -2,13 +2,18 @@ package com.gemwallet.android.data.coordinators.asset_select import com.gemwallet.android.application.asset_select.coordinators.UpdateRecentAsset import com.gemwallet.android.data.repositories.assets.AssetsRepository +import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.model.RecentType import com.wallet.core.primitives.AssetId -import com.wallet.core.primitives.WalletId +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first class UpdateRecentAssetImpl( + private val sessionRepository: SessionRepository, private val assetsRepository: AssetsRepository, ) : UpdateRecentAsset { - override suspend fun invoke(assetId: AssetId, walletId: WalletId, type: RecentType) = - assetsRepository.addRecentActivity(assetId, walletId.id, type) + override suspend fun invoke(assetId: AssetId, type: RecentType) { + val wallet = sessionRepository.session().filterNotNull().first().wallet + assetsRepository.addRecentActivity(assetId, wallet.id.id, type) + } } diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetSelectModule.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetSelectModule.kt index 45087c1600..b0c56097de 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetSelectModule.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetSelectModule.kt @@ -61,8 +61,9 @@ object AssetSelectModule { @Provides @Singleton fun provideUpdateRecentAsset( + sessionRepository: SessionRepository, assetsRepository: AssetsRepository, - ): UpdateRecentAsset = UpdateRecentAssetImpl(assetsRepository) + ): UpdateRecentAsset = UpdateRecentAssetImpl(sessionRepository, assetsRepository) @Provides @Singleton diff --git a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt index 5cd5a5b381..d35071966e 100644 --- a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt +++ b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt @@ -214,8 +214,7 @@ open class BaseAssetSelectViewModel( } fun updateRecent(assetId: AssetId, type: RecentType) = viewModelScope.launch(Dispatchers.IO) { - val walletId = session.value?.wallet?.id ?: return@launch - updateRecentAsset(assetId, walletId, type) + updateRecentAsset(assetId, type) } open val showRecents: Boolean get() = true diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/asset_select/coordinators/UpdateRecentAsset.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/asset_select/coordinators/UpdateRecentAsset.kt index d52eee4f36..0c323fd761 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/asset_select/coordinators/UpdateRecentAsset.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/asset_select/coordinators/UpdateRecentAsset.kt @@ -2,8 +2,7 @@ package com.gemwallet.android.application.asset_select.coordinators import com.gemwallet.android.model.RecentType import com.wallet.core.primitives.AssetId -import com.wallet.core.primitives.WalletId interface UpdateRecentAsset { - suspend operator fun invoke(assetId: AssetId, walletId: WalletId, type: RecentType) + suspend operator fun invoke(assetId: AssetId, type: RecentType) } From f58328e712413e724708606e149b3384270c78f0 Mon Sep 17 00:00:00 2001 From: gemdev1111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:17:27 +0300 Subject: [PATCH 2/7] Add recent perpetuals to market search (Android) Record a recent activity when a perpetual is opened, and surface recent perpetuals as a chip row when entering search (matching iOS). Selecting a recent navigates without re-recording (no reordering), and the "Recent" header opens the shared recents sheet for the full list. Add a centered "No markets found" empty state (SearchPerpetuals) shown only while searching, with the standard search icon + "check spelling" subtitle. Recents hide once a query is typed, and search state is retained across the perpetual-details round-trip. --- .../perpetual/presents/build.gradle.kts | 2 + .../views/market/PerpetualMarketAction.kt | 2 + .../views/market/PerpetualMarketNavScreen.kt | 18 +++- .../views/market/PerpetualMarketScene.kt | 101 ++++++++++++++++-- .../viewmodels/PerpetualMarketViewModel.kt | 21 +++- .../com/gemwallet/android/model/RecentType.kt | 2 + .../ui/components/empty/EmptyContentType.kt | 1 + .../ui/components/empty/EmptyContentView.kt | 6 +- 8 files changed, 138 insertions(+), 15 deletions(-) diff --git a/android/features/perpetual/presents/build.gradle.kts b/android/features/perpetual/presents/build.gradle.kts index 15f1198bb6..413d084344 100644 --- a/android/features/perpetual/presents/build.gradle.kts +++ b/android/features/perpetual/presents/build.gradle.kts @@ -54,6 +54,8 @@ android { dependencies { implementation(project(":ui")) implementation(project(":features:perpetual:viewmodels")) + implementation(project(":features:asset_select:viewmodels")) + implementation(project(":features:asset_select:presents")) implementation(libs.hilt.android) ksp(libs.hilt.compiler) diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketAction.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketAction.kt index ae2bd5653d..fc71aa8938 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketAction.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketAction.kt @@ -10,4 +10,6 @@ internal sealed interface PerpetualMarketAction { data object Close : PerpetualMarketAction data class TogglePin(val perpetualId: PerpetualId) : PerpetualMarketAction data class OpenPerpetual(val assetId: AssetId) : PerpetualMarketAction + data class OpenRecent(val assetId: AssetId) : PerpetualMarketAction + data object OpenRecentsSheet : PerpetualMarketAction } diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt index dd75ba52ac..a288ff2493 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt @@ -7,7 +7,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.snapshotFlow import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.asset_select.presents.views.RecentsSheetHost +import com.gemwallet.android.features.asset_select.viewmodels.RecentsSheetViewModel import com.gemwallet.android.features.perpetual.viewmodels.PerpetualMarketViewModel +import com.gemwallet.android.model.RecentType import com.wallet.core.primitives.AssetId @Composable @@ -15,12 +18,14 @@ fun PerpetualMarketNavScreen( onCancel: () -> Unit, onOpenPerpetualDetails: (AssetId) -> Unit, viewModel: PerpetualMarketViewModel = hiltViewModel(), + recentsViewModel: RecentsSheetViewModel = hiltViewModel(), ) { val sceneState by viewModel.sceneState.collectAsStateWithLifecycle() val unpinnedPerpetuals by viewModel.unpinnedPerpetuals.collectAsStateWithLifecycle() val pinnedPerpetuals by viewModel.pinnedPerpetuals.collectAsStateWithLifecycle() val positions by viewModel.positions.collectAsStateWithLifecycle() val balance by viewModel.balance.collectAsStateWithLifecycle() + val recent by viewModel.recent.collectAsStateWithLifecycle() val query = rememberTextFieldState() LaunchedEffect(query) { @@ -35,6 +40,7 @@ fun PerpetualMarketNavScreen( unpinnedPerpetuals = unpinnedPerpetuals, pinnedPerpetuals = pinnedPerpetuals, positions = positions, + recent = recent, query = query, onAction = { action -> when (action) { @@ -43,8 +49,18 @@ fun PerpetualMarketNavScreen( PerpetualMarketAction.Withdraw -> Unit PerpetualMarketAction.Deposit -> Unit is PerpetualMarketAction.TogglePin -> viewModel.onTogglePin(action.perpetualId) - is PerpetualMarketAction.OpenPerpetual -> onOpenPerpetualDetails(action.assetId) + is PerpetualMarketAction.OpenPerpetual -> { + onOpenPerpetualDetails(action.assetId) + viewModel.onOpenPerpetual(action.assetId) + } + is PerpetualMarketAction.OpenRecent -> onOpenPerpetualDetails(action.assetId) + PerpetualMarketAction.OpenRecentsSheet -> recentsViewModel.show(types = listOf(RecentType.Perpetual)) } }, ) + + RecentsSheetHost( + viewModel = recentsViewModel, + onSelect = onOpenPerpetualDetails, + ) } diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketScene.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketScene.kt index 58d9429edc..10a91b4aff 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketScene.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketScene.kt @@ -1,7 +1,15 @@ package com.gemwallet.android.features.perpetual.views.market +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.material3.Icon @@ -15,9 +23,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -30,6 +40,9 @@ import com.gemwallet.android.domains.price.ValueDirection import com.gemwallet.android.domains.price.values.EquivalentValue import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.clickable +import com.gemwallet.android.ui.components.empty.EmptyContentType +import com.gemwallet.android.ui.components.empty.EmptyContentView +import com.gemwallet.android.ui.components.image.AssetIcon import com.gemwallet.android.ui.components.list_head.AmountListHead import com.gemwallet.android.ui.components.list_item.PinnedAssetsHeaderItem import com.gemwallet.android.ui.components.list_item.SubheaderItem @@ -39,6 +52,10 @@ import com.gemwallet.android.ui.icons.AppIcons import com.gemwallet.android.ui.models.AssetsGroupType import com.gemwallet.android.ui.theme.Spacer16 import com.gemwallet.android.ui.theme.WalletTheme +import com.gemwallet.android.ui.theme.paddingDefault +import com.gemwallet.android.ui.theme.paddingHalfSmall +import com.gemwallet.android.ui.theme.paddingSmall +import com.gemwallet.android.ui.theme.smallIconSize import com.gemwallet.android.features.perpetual.viewmodels.model.PerpetualMarketSceneState import com.gemwallet.android.features.perpetual.views.components.MarketHeadActions import com.gemwallet.android.features.perpetual.views.components.PerpetualItem @@ -59,12 +76,15 @@ internal fun PerpetualMarketScene( positions: List, unpinnedPerpetuals: List, pinnedPerpetuals: List, + recent: List = emptyList(), query: TextFieldState, onAction: (PerpetualMarketAction) -> Unit, ) { val pullToRefreshState = rememberPullToRefreshState() val longPressedAsset = remember { mutableStateOf(null) } - var isSearching by remember { mutableStateOf(false) } + var isSearching by rememberSaveable { mutableStateOf(false) } + val showRecents = isSearching && query.text.isEmpty() && recent.isNotEmpty() + val showMarkets = !isSearching || unpinnedPerpetuals.isNotEmpty() || positions.isEmpty() Scene( titleContent = { @@ -109,6 +129,13 @@ internal fun PerpetualMarketScene( LazyColumn( modifier = Modifier.fillMaxSize() ) { + if (showRecents) { + recentPerpetuals( + items = recent, + onSeeAll = { onAction(PerpetualMarketAction.OpenRecentsSheet) }, + onSelect = { assetId -> onAction(PerpetualMarketAction.OpenRecent(assetId)) }, + ) + } if (!isSearching) { item { AmountListHead( @@ -150,17 +177,69 @@ internal fun PerpetualMarketScene( ) } } - item { - SubheaderItem(R.string.markets_title) + if (showMarkets) { + if (unpinnedPerpetuals.isEmpty()) { + if (isSearching) { + item { + EmptyContentView( + type = EmptyContentType.SearchPerpetuals, + modifier = Modifier + .animateItem() + .fillParentMaxSize(), + ) + } + } + } else { + item { + SubheaderItem(R.string.markets_title) + } + itemsPositioned(unpinnedPerpetuals) { position, item -> + PerpetualItem( + item = item, + listPosition = position, + longPressState = longPressedAsset, + onTogglePin = { onAction(PerpetualMarketAction.TogglePin(it)) }, + onClick = { onAction(PerpetualMarketAction.OpenPerpetual(it)) }, + ) + } + } } - itemsPositioned(unpinnedPerpetuals) { position, item -> - PerpetualItem( - item = item, - listPosition = position, - longPressState = longPressedAsset, - onTogglePin = { onAction(PerpetualMarketAction.TogglePin(it)) }, - onClick = { onAction(PerpetualMarketAction.OpenPerpetual(it)) }, - ) + } + } + } +} + +private fun LazyListScope.recentPerpetuals( + items: List, + onSeeAll: () -> Unit, + onSelect: (AssetId) -> Unit, +) { + if (items.isEmpty()) { + return + } + item { SubheaderItem(R.string.recent_activity_title, onClick = onSeeAll) } + item { + LazyRow( + modifier = Modifier.padding( + top = paddingHalfSmall, + start = paddingDefault, + bottom = paddingSmall, + end = paddingDefault, + ), + horizontalArrangement = Arrangement.spacedBy(paddingSmall), + ) { + items(items) { asset -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.background) + .clickable { onSelect(asset.id) } + .padding(paddingSmall), + horizontalArrangement = Arrangement.spacedBy(paddingSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + AssetIcon(asset, size = smallIconSize) + Text(asset.symbol) } } } diff --git a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualMarketViewModel.kt b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualMarketViewModel.kt index 8b114d59f4..844e419aa5 100644 --- a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualMarketViewModel.kt +++ b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualMarketViewModel.kt @@ -2,6 +2,8 @@ package com.gemwallet.android.features.perpetual.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.gemwallet.android.application.asset_select.coordinators.GetRecentAssets +import com.gemwallet.android.application.asset_select.coordinators.UpdateRecentAsset import com.gemwallet.android.application.perpetual.coordinators.GetPerpetualBalances import com.gemwallet.android.application.perpetual.coordinators.GetPerpetualPositions import com.gemwallet.android.application.perpetual.coordinators.GetPerpetuals @@ -11,6 +13,10 @@ import com.gemwallet.android.application.perpetual.coordinators.TogglePerpetualP import com.gemwallet.android.domains.perpetual.values.PerpetualBalance import com.gemwallet.android.features.perpetual.viewmodels.model.PerpetualMarketSceneState import com.gemwallet.android.model.CurrencyFormatter +import com.gemwallet.android.model.RecentAssetsRequest +import com.gemwallet.android.model.RecentType +import com.wallet.core.primitives.Asset +import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PerpetualId import dagger.hilt.android.lifecycle.HiltViewModel @@ -18,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -33,7 +40,9 @@ class PerpetualMarketViewModel @Inject constructor( private val getBalance: GetPerpetualBalances, private val syncPerpetuals: SyncPerpetuals, private val syncPerpetualPositions: SyncPerpetualPositions, - private val togglePin: TogglePerpetualPin + private val togglePin: TogglePerpetualPin, + private val getRecentAssets: GetRecentAssets, + private val updateRecentAsset: UpdateRecentAsset, ) : ViewModel() { val query = MutableStateFlow(null) @@ -57,6 +66,10 @@ class PerpetualMarketViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) val balance = getBalance.getPerpetualBalance() .stateIn(viewModelScope, SharingStarted.Eagerly, EmptyPerpetualBalance) + val recent: StateFlow> = + getRecentAssets(RecentAssetsRequest(types = listOf(RecentType.Perpetual))) + .map { items -> items.map { it.asset } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) fun onRefresh() { sceneState.update { PerpetualMarketSceneState.Refreshing } @@ -83,6 +96,12 @@ class PerpetualMarketViewModel @Inject constructor( fun onTogglePin(perpetualId: PerpetualId) { togglePin.togglePin(perpetualId) } + + fun onOpenPerpetual(assetId: AssetId) { + viewModelScope.launch(Dispatchers.IO) { + updateRecentAsset(assetId, RecentType.Perpetual) + } + } } private object EmptyPerpetualBalance : PerpetualBalance { diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/RecentType.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/RecentType.kt index cd208b6216..0498e0bfa9 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/RecentType.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/RecentType.kt @@ -15,4 +15,6 @@ enum class RecentType { Buy, @SerialName("Swap") Swap, + @SerialName("Perpetual") + Perpetual, } \ No newline at end of file diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/empty/EmptyContentType.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/empty/EmptyContentType.kt index dc36b16434..f55efbef7d 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/empty/EmptyContentType.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/empty/EmptyContentType.kt @@ -16,6 +16,7 @@ sealed interface EmptyContentType { data class SearchAssets(val onAddCustomToken: (() -> Unit)? = null) : EmptyContentType data class SearchActivity(val onClearFilters: (() -> Unit)? = null) : EmptyContentType data object SearchNetworks : EmptyContentType + data object SearchPerpetuals : EmptyContentType data class Stake(val symbol: String) : EmptyContentType data object PriceAlerts : EmptyContentType diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/empty/EmptyContentView.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/empty/EmptyContentView.kt index 2776b1b3d8..c1c7a88d07 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/empty/EmptyContentView.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/empty/EmptyContentView.kt @@ -44,6 +44,7 @@ private fun EmptyContentType.title(): String = when (this) { is EmptyContentType.SearchAssets -> stringResource(R.string.assets_no_assets_found) is EmptyContentType.SearchActivity -> stringResource(R.string.activity_state_empty_search_title) is EmptyContentType.SearchNetworks -> stringResource(R.string.networks_state_empty_search_title) + is EmptyContentType.SearchPerpetuals -> stringResource(R.string.perpetuals_empty_state_no_markets_found) } @Composable @@ -63,12 +64,13 @@ private fun EmptyContentType.description(): String? = when (this) { } is EmptyContentType.SearchActivity -> stringResource(R.string.activity_state_empty_search_description) is EmptyContentType.SearchNetworks -> stringResource(R.string.search_state_empty_description) + is EmptyContentType.SearchPerpetuals -> stringResource(R.string.search_state_empty_description) } @Composable private fun EmptyContentType.icon() = when (this) { is EmptyContentType.SearchAssets, is EmptyContentType.SearchActivity, - is EmptyContentType.SearchNetworks -> null + is EmptyContentType.SearchNetworks, is EmptyContentType.SearchPerpetuals -> null is EmptyContentType.Nft -> painterResource(R.drawable.empty_nfts) is EmptyContentType.PriceAlerts -> painterResource(R.drawable.empty_notifications) is EmptyContentType.Asset, is EmptyContentType.Activity -> painterResource(R.drawable.empty_activity) @@ -81,7 +83,7 @@ private fun EmptyContentType.icon() = when (this) { @Composable private fun EmptyContentType.iconVector(): ImageVector? = when (this) { is EmptyContentType.SearchAssets, is EmptyContentType.SearchActivity, - is EmptyContentType.SearchNetworks -> AppIcons.Search + is EmptyContentType.SearchNetworks, is EmptyContentType.SearchPerpetuals -> AppIcons.Search else -> null } From f54066a80eba81d0c24cfc8cfb79c9456e73d4b0 Mon Sep 17 00:00:00 2001 From: gemdev1111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:17:27 +0300 Subject: [PATCH 3/7] Polish perpetuals search empty state (iOS) Show a centered "No markets found" overlay when a search returns nothing, using a new .search(type: .perpetuals) empty type, replacing the inline placeholder text and its lonely "Markets" section header. Hide recents once a query is typed so the empty state stands alone, and drop the now-unused emptyText path from PerpetualSectionView. --- .../Sources/Scenes/PerpetualsScene.swift | 6 +++- .../ViewModels/PerpetualsSceneViewModel.swift | 12 +++++--- .../Sources/Views/PerpetualSectionView.swift | 30 ++++--------------- .../Sources/Types/EmptyContentType.swift | 1 + .../EmptyContentTypeViewModel.swift | 5 ++-- 5 files changed, 23 insertions(+), 31 deletions(-) diff --git a/ios/Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift b/ios/Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift index 1266f527b1..37bd02f167 100644 --- a/ios/Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift +++ b/ios/Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift @@ -130,7 +130,6 @@ struct PerpetualsScene: View { perpetuals: model.sections.markets, onPin: model.onPinPerpetual, onSelect: model.onSelectPerpetual, - emptyText: model.noMarketsText, ) } header: { Text(model.marketsSectionTitle) @@ -141,5 +140,10 @@ struct PerpetualsScene: View { .if(!model.isSearching) { $0.contentMargins([.top], .space12, for: .scrollContent) } + .overlay { + if model.showSearchEmptyState { + EmptyContentView(model: model.emptyContentModel) + } + } } } diff --git a/ios/Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift b/ios/Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift index dd5a560b09..50ed4e4cbf 100644 --- a/ios/Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift +++ b/ios/Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift @@ -90,8 +90,8 @@ final class PerpetualsSceneViewModel { Localized.Common.pinned } - var noMarketsText: String? { - !isSearching ? Localized.Perpetuals.EmptyState.noMarkets : Localized.Perpetuals.EmptyState.noMarketsFound + var emptyContentModel: EmptyContentTypeViewModel { + EmptyContentTypeViewModel(type: .search(type: .perpetuals)) } var pinImage: Image { @@ -111,11 +111,15 @@ final class PerpetualsSceneViewModel { } var showMarkets: Bool { - !isSearching || sections.markets.isNotEmpty || positions.isEmpty + sections.markets.isNotEmpty } var showRecents: Bool { - isSearching && recents.isNotEmpty + isSearching && searchQuery.isEmpty && recents.isNotEmpty + } + + var showSearchEmptyState: Bool { + isSearching && !showPositions && !showPinned && !showMarkets } var sections: PerpetualsSections { diff --git a/ios/Features/Perpetuals/Sources/Views/PerpetualSectionView.swift b/ios/Features/Perpetuals/Sources/Views/PerpetualSectionView.swift index 761e759405..94b3d944c1 100644 --- a/ios/Features/Perpetuals/Sources/Views/PerpetualSectionView.swift +++ b/ios/Features/Perpetuals/Sources/Views/PerpetualSectionView.swift @@ -7,32 +7,14 @@ struct PerpetualSectionView: View { let perpetuals: [PerpetualData] let onPin: (PerpetualId, Bool) -> Void let onSelect: (Asset) -> Void - let emptyText: String? - - init( - perpetuals: [PerpetualData], - onPin: @escaping (PerpetualId, Bool) -> Void, - onSelect: @escaping (Asset) -> Void, - emptyText: String? = nil, - ) { - self.perpetuals = perpetuals - self.onPin = onPin - self.onSelect = onSelect - self.emptyText = emptyText - } var body: some View { - if perpetuals.isEmpty, let emptyText { - Text(emptyText) - .foregroundStyle(.secondary) - } else { - ForEach(perpetuals) { perpetualData in - PerpetualListItem( - perpetualData: perpetualData, - onPin: onPin, - onSelect: onSelect, - ) - } + ForEach(perpetuals) { perpetualData in + PerpetualListItem( + perpetualData: perpetualData, + onPin: onPin, + onSelect: onSelect, + ) } } } diff --git a/ios/Packages/Components/Sources/Types/EmptyContentType.swift b/ios/Packages/Components/Sources/Types/EmptyContentType.swift index 4dbca9898c..b50522f4f6 100644 --- a/ios/Packages/Components/Sources/Types/EmptyContentType.swift +++ b/ios/Packages/Components/Sources/Types/EmptyContentType.swift @@ -7,6 +7,7 @@ public enum EmptyContentType { case assets case networks case activity + case perpetuals } case nfts(action: (() -> Void)? = nil) diff --git a/ios/Packages/PrimitivesComponents/Sources/ViewModels/EmptyContentTypeViewModel.swift b/ios/Packages/PrimitivesComponents/Sources/ViewModels/EmptyContentTypeViewModel.swift index c1d5de6330..1fd540d453 100644 --- a/ios/Packages/PrimitivesComponents/Sources/ViewModels/EmptyContentTypeViewModel.swift +++ b/ios/Packages/PrimitivesComponents/Sources/ViewModels/EmptyContentTypeViewModel.swift @@ -38,6 +38,7 @@ public struct EmptyContentTypeViewModel: EmptyContentViewable { case .assets: Localized.Assets.noAssetsFound case .networks: Localized.Networks.State.Empty.searchTitle case .activity: Localized.Activity.State.Empty.searchTitle + case .perpetuals: Localized.Perpetuals.EmptyState.noMarketsFound } case .recents: Localized.RecentActivity.State.Empty.title case .contacts: Localized.Contacts.State.Empty.title @@ -64,7 +65,7 @@ public struct EmptyContentTypeViewModel: EmptyContentViewable { case let .search(searchType, action): switch searchType { case .assets: action != nil ? Localized.Assets.State.Empty.searchDescription : Localized.Search.State.Empty.description - case .networks: Localized.Search.State.Empty.description + case .networks, .perpetuals: Localized.Search.State.Empty.description case .activity: Localized.Activity.State.Empty.searchDescription } case .markets: .none @@ -119,7 +120,7 @@ public struct EmptyContentTypeViewModel: EmptyContentViewable { switch searchType { case .assets: [EmptyAction(title: Localized.Assets.addCustomToken, action: action)] - case .networks: + case .networks, .perpetuals: [] case .activity: [EmptyAction(title: Localized.Filter.clear, action: action)] From a2b9a30c09ddcb2cec9ff48034b6b1ad3a8cbaa3 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:09:23 +0300 Subject: [PATCH 4/7] Add SwapSelect recent activity type New RecentActivityType/RecentType variant to distinguish assets picked in the swap selector from completed swaps. Added to the core enum, regenerated iOS/Android bindings, and Android's hand-written RecentType. --- .../src/main/kotlin/com/gemwallet/android/model/RecentType.kt | 2 ++ .../com/wallet/core/primitives/generated/RecentActivityType.kt | 2 ++ core/crates/primitives/src/recent_activity_type.rs | 1 + .../Primitives/Sources/Generated/RecentActivityType.swift | 1 + 4 files changed, 6 insertions(+) diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/RecentType.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/RecentType.kt index 0498e0bfa9..c2e1c37074 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/model/RecentType.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/model/RecentType.kt @@ -15,6 +15,8 @@ enum class RecentType { Buy, @SerialName("Swap") Swap, + @SerialName("SwapSelect") + SwapSelect, @SerialName("Perpetual") Perpetual, } \ No newline at end of file diff --git a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/RecentActivityType.kt b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/RecentActivityType.kt index 8d32ea9dd5..4e7eafe1cd 100644 --- a/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/RecentActivityType.kt +++ b/android/gemcore/src/main/kotlin/com/wallet/core/primitives/generated/RecentActivityType.kt @@ -21,6 +21,8 @@ enum class RecentActivityType(val string: String) { FiatSell("fiatSell"), @SerialName("swap") Swap("swap"), + @SerialName("swapSelect") + SwapSelect("swapSelect"), @SerialName("perpetual") Perpetual("perpetual"), } diff --git a/core/crates/primitives/src/recent_activity_type.rs b/core/crates/primitives/src/recent_activity_type.rs index 2d6bf46b4b..462b4a4a56 100644 --- a/core/crates/primitives/src/recent_activity_type.rs +++ b/core/crates/primitives/src/recent_activity_type.rs @@ -11,5 +11,6 @@ pub enum RecentActivityType { FiatBuy, FiatSell, Swap, + SwapSelect, Perpetual, } diff --git a/ios/Packages/Primitives/Sources/Generated/RecentActivityType.swift b/ios/Packages/Primitives/Sources/Generated/RecentActivityType.swift index 1a8915e1fd..66246f3cf5 100644 --- a/ios/Packages/Primitives/Sources/Generated/RecentActivityType.swift +++ b/ios/Packages/Primitives/Sources/Generated/RecentActivityType.swift @@ -11,5 +11,6 @@ public enum RecentActivityType: String, Codable, CaseIterable, Equatable, Sendab case fiatBuy case fiatSell case swap + case swapSelect case perpetual } From 44f07545e95f9a885c92ed19ba6951c7feeb5197 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:09:23 +0300 Subject: [PATCH 5/7] Record and scope swap selector recents (iOS) Picking an asset in the swap pay/receive selector records a SwapSelect recent (SelectAssetType.swap -> .swapSelect), and the selector shows recents scoped to [swapSelect, swap]. Selecting an existing recent completes without re-recording, matching the other selectors. Completed swaps keep their distinct .swap type. Adds SelectAssetType mapping tests. --- .../Sources/Scenes/SelectAssetScene.swift | 8 +------ .../ViewModels/SelectAssetViewModel.swift | 9 ++++--- .../Primitives/Sources/SelectAssetType.swift | 10 +++++++- .../SelectAssetTypeTests.swift | 24 +++++++++++++++++++ 4 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 ios/Packages/Primitives/Tests/PrimitivesTests/SelectAssetTypeTests.swift diff --git a/ios/Features/Assets/Sources/Scenes/SelectAssetScene.swift b/ios/Features/Assets/Sources/Scenes/SelectAssetScene.swift index 2b38d0da70..99aec55888 100644 --- a/ios/Features/Assets/Sources/Scenes/SelectAssetScene.swift +++ b/ios/Features/Assets/Sources/Scenes/SelectAssetScene.swift @@ -77,18 +77,12 @@ public struct SelectAssetScene: View { onSelectRecents: model.onSelectRecents, ) { assetModel in switch model.selectType { - case .send, .receive, .buy: + case .send, .receive, .buy, .swap: Button { model.onSelectRecent(assetModel.asset) } label: { AssetChipView(model: assetModel) } - case .swap: - Button { - model.selectAsset(asset: assetModel.asset) - } label: { - AssetChipView(model: assetModel) - } case .manage, .priceAlert, .deposit, .withdraw: EmptyView() } diff --git a/ios/Features/Assets/Sources/ViewModels/SelectAssetViewModel.swift b/ios/Features/Assets/Sources/ViewModels/SelectAssetViewModel.swift index 4390024f8d..4fd84c60b4 100644 --- a/ios/Features/Assets/Sources/ViewModels/SelectAssetViewModel.swift +++ b/ios/Features/Assets/Sources/ViewModels/SelectAssetViewModel.swift @@ -84,7 +84,7 @@ public final class SelectAssetViewModel { RecentActivityRequest( walletId: wallet.id, limit: 10, - types: RecentActivityType.allCases, + types: selectType.recentActivityTypes, filters: filter.defaultFilters, ), initialValue: [], @@ -233,7 +233,10 @@ extension SelectAssetViewModel { Task { await setPriceAlert(assetId: asset.id, enabled: true) } - case .manage, .send, .receive, .buy, .swap, .deposit, .withdraw: break + case .swap: + updateRecent(assetId: asset.id) + case .manage, .send, .receive, .buy, .deposit, .withdraw: + break } onSelectAssetAction?(asset) } @@ -330,7 +333,7 @@ extension SelectAssetViewModel { case .send, .receive, .buy: assetSelection = .recent(SelectAssetInput(type: selectType, assetAddress: assetAddress(for: asset))) case .swap: - selectAsset(asset: asset) + onSelectAssetAction?(asset) case .manage, .priceAlert, .deposit, .withdraw: break } diff --git a/ios/Packages/Primitives/Sources/SelectAssetType.swift b/ios/Packages/Primitives/Sources/SelectAssetType.swift index 7243ef5165..8446701cb1 100644 --- a/ios/Packages/Primitives/Sources/SelectAssetType.swift +++ b/ios/Packages/Primitives/Sources/SelectAssetType.swift @@ -31,7 +31,15 @@ public extension SelectAssetType { switch self { case .receive: RecentActivityData(type: .receive, assetId: assetId, toAssetId: nil) case .buy: RecentActivityData(type: .fiatBuy, assetId: assetId, toAssetId: nil) - case .send, .swap, .manage, .priceAlert, .deposit, .withdraw: .none + case .swap: RecentActivityData(type: .swapSelect, assetId: assetId, toAssetId: nil) + case .send, .manage, .priceAlert, .deposit, .withdraw: .none + } + } + + var recentActivityTypes: [RecentActivityType] { + switch self { + case .swap: [.swapSelect, .swap] + case .send, .receive, .buy, .manage, .priceAlert, .deposit, .withdraw: RecentActivityType.allCases } } } diff --git a/ios/Packages/Primitives/Tests/PrimitivesTests/SelectAssetTypeTests.swift b/ios/Packages/Primitives/Tests/PrimitivesTests/SelectAssetTypeTests.swift new file mode 100644 index 0000000000..2a0517fe65 --- /dev/null +++ b/ios/Packages/Primitives/Tests/PrimitivesTests/SelectAssetTypeTests.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives +import Testing + +struct SelectAssetTypeTests { + @Test + func recentActivityTypes() { + #expect(SelectAssetType.swap(.pay).recentActivityTypes == [.swapSelect, .swap]) + #expect(SelectAssetType.swap(.receive(chains: [], assetIds: [])).recentActivityTypes == [.swapSelect, .swap]) + #expect(SelectAssetType.receive(.asset).recentActivityTypes == RecentActivityType.allCases) + #expect(SelectAssetType.buy.recentActivityTypes == RecentActivityType.allCases) + } + + @Test + func recentActivityData() { + let assetId = AssetId(chain: .bitcoin) + #expect(SelectAssetType.swap(.pay).recentActivityData(assetId: assetId)?.type == .swapSelect) + #expect(SelectAssetType.swap(.receive(chains: [], assetIds: [])).recentActivityData(assetId: assetId)?.type == .swapSelect) + #expect(SelectAssetType.receive(.asset).recentActivityData(assetId: assetId)?.type == .receive) + #expect(SelectAssetType.buy.recentActivityData(assetId: assetId)?.type == .fiatBuy) + #expect(SelectAssetType.send.recentActivityData(assetId: assetId) == nil) + } +} From 4c0fa97c07b669c16a221758ab212a0b347b0ef9 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:09:23 +0300 Subject: [PATCH 6/7] Record and scope swap selector recents (Android) The swap selector records a SwapSelect recent on pick and shows recents scoped to [SwapSelect, Swap] via an overridable recentTypes on BaseAssetSelectViewModel. Re-tapping a recent does not re-record. Completed swaps keep RecentType.Swap. --- .../asset_select/viewmodels/BaseAssetSelectViewModel.kt | 4 +++- .../android/features/swap/views/SwapSelectScreen.kt | 5 +++-- .../android/features/swap/viewmodels/SwapSelectViewModel.kt | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt index d35071966e..02f1dd80c2 100644 --- a/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt +++ b/android/features/asset_select/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset_select/viewmodels/BaseAssetSelectViewModel.kt @@ -130,7 +130,7 @@ open class BaseAssetSelectViewModel( if (query.isNotEmpty() || !showRecents) { flow { emit(emptyList()) } } else { - getRecentAssets(RecentAssetsRequest(filters = assetFilters())) + getRecentAssets(RecentAssetsRequest(types = recentTypes, filters = assetFilters())) } } .map { items -> items.map { it.asset }.toImmutableList() } @@ -219,6 +219,8 @@ open class BaseAssetSelectViewModel( open val showRecents: Boolean get() = true + open val recentTypes: List get() = RecentType.entries + open fun assetFilters(): Set = emptySet() private companion object { diff --git a/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapSelectScreen.kt b/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapSelectScreen.kt index 21ec70863d..bb67a24279 100644 --- a/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapSelectScreen.kt +++ b/android/features/swap/presents/src/main/kotlin/com/gemwallet/android/features/swap/views/SwapSelectScreen.kt @@ -16,6 +16,7 @@ import com.gemwallet.android.features.asset_select.presents.views.RecentsSheetHo import com.gemwallet.android.features.asset_select.viewmodels.RecentsSheetViewModel import com.gemwallet.android.features.swap.viewmodels.SwapSelectViewModel import com.gemwallet.android.domains.swap.SwapItemType +import com.gemwallet.android.model.RecentType import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.AssetSubtype import kotlinx.collections.immutable.toImmutableList @@ -66,9 +67,9 @@ fun SwapSelectScreen( onChainFilter = viewModel::onChainFilter, onBalanceFilter = viewModel::onBalanceFilter, onClearFilters = viewModel::onClearFilters, - onSelect = onSelectAsset, + onSelect = { viewModel.updateRecent(it, RecentType.SwapSelect); onSelectAsset(it) }, onSelectRecent = onSelectAsset, - onOpenRecentsSheet = { recentsViewModel.show(filters = viewModel.assetFilters()) }, + onOpenRecentsSheet = { recentsViewModel.show(filters = viewModel.assetFilters(), types = viewModel.recentTypes) }, onCancel = onCancel, onAddAsset = null, itemTrailing = { getBalanceInfo(it)() }, diff --git a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectViewModel.kt b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectViewModel.kt index 32045695d8..20bcf5b5fe 100644 --- a/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectViewModel.kt +++ b/android/features/swap/viewmodels/src/main/kotlin/com/gemwallet/android/features/swap/viewmodels/SwapSelectViewModel.kt @@ -17,6 +17,7 @@ import com.gemwallet.android.features.asset_select.viewmodels.models.SelectAsset import com.gemwallet.android.features.asset_select.viewmodels.models.SelectSearch import com.gemwallet.android.model.AssetFilter import com.gemwallet.android.model.AssetInfo +import com.gemwallet.android.model.RecentType import com.gemwallet.android.ui.models.navigation.RouteArgument import com.wallet.core.primitives.AssetId import dagger.hilt.android.lifecycle.HiltViewModel @@ -75,6 +76,8 @@ class SwapSelectViewModel @Inject constructor( } override fun assetFilters() = setOf(AssetFilter.Swappable) + + override val recentTypes: List get() = listOf(RecentType.SwapSelect, RecentType.Swap) } private fun SavedStateHandle.requireSwapItemType(): SwapItemType = From 12af23a9cbd934ed9ea33f2b2deb00e8d62ce948 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:09:23 +0300 Subject: [PATCH 7/7] Scope recents clear to the displayed types (iOS) RecentsSceneViewModel clear now clears only the types the sheet was opened with instead of all recent types, so clearing from the swap (or perpetual) selector no longer wipes unrelated recents. --- .../Recents/Sources/ViewModels/RecentsSceneViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Features/Recents/Sources/ViewModels/RecentsSceneViewModel.swift b/ios/Features/Recents/Sources/ViewModels/RecentsSceneViewModel.swift index 3ce80c8571..69ffb57c4c 100644 --- a/ios/Features/Recents/Sources/ViewModels/RecentsSceneViewModel.swift +++ b/ios/Features/Recents/Sources/ViewModels/RecentsSceneViewModel.swift @@ -77,7 +77,7 @@ public final class RecentsSceneViewModel { extension RecentsSceneViewModel { func onSelectClear() { do { - try activityService.clearRecent(walletId: walletId, types: RecentActivityType.allCases) + try activityService.clearRecent(walletId: walletId, types: query.request.types) } catch { debugLog("RecentsSceneViewModel clear error: \(error)") }