diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index d836fce188..f0a6cb16c5 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.features.swap.domain) implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) + implementation(projects.features.feed.api) /** Project - Core */ implementation(projects.core.analytics) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt index d9b66c559d..ee2dc2dd5b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt @@ -5,13 +5,20 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent import com.tangem.features.onramp.component.SwapSelectTokensComponent import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent +import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.swap.model.SwapSelectTokensModel import com.tangem.features.onramp.swap.ui.SwapSelectTokens import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent @@ -25,6 +32,7 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( tokenListComponentFactory: OnrampTokenListComponent.Factory, availableSwapPairsComponentFactory: AvailableSwapPairsComponent.Factory, analyticsEventHandler: AnalyticsEventHandler, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: SwapSelectTokensComponent.Params, ) : AppComponentContext by appComponentContext, SwapSelectTokensComponent { @@ -49,15 +57,36 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( ), ) + private val bottomSheetSlot = childSlot( + source = selectToTokenListComponent.bottomSheetNavigation, + serializer = AddToPortfolioRoute.serializer(), + key = "add_to_portfolio_bottom_sheet", + handleBackButton = false, + childFactory = { _, context -> bottomSheetChild(context) }, + ) + init { analyticsEventHandler.send(event = MainScreenAnalyticsEvent.SwapScreenOpened()) } + @Suppress("UnsafeCallOnNullableType") + private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent { + return addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager!!, + callback = selectToTokenListComponent.addToPortfolioCallback, + shouldSkipTokenActionsScreen = true, + ), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() val fromTokensState by selectFromTokenListComponent.uiState.collectAsStateWithLifecycle() val toTokensState by selectToTokenListComponent.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() SwapSelectTokens( state = state, @@ -67,6 +96,8 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( selectToTokenListState = toTokensState, modifier = modifier, ) + + bottomSheet.child?.instance?.BottomSheet() } @AssistedFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt index cb87a50dc2..bae0b5ba51 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt @@ -1,11 +1,15 @@ package com.tangem.features.onramp.swap.availablepairs import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.tokenlist.entity.TokenListUM import kotlinx.coroutines.flow.StateFlow @@ -13,6 +17,10 @@ import kotlinx.coroutines.flow.StateFlow @Stable internal interface AvailableSwapPairsComponent : ComposableListContentComponent { + val bottomSheetNavigation: SlotNavigation + val addToPortfolioManager: AddToPortfolioManager? + val addToPortfolioCallback: AddToPortfolioComponent.Callback + /** Component factory */ interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt index 456b6e8c30..27e528cd04 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -3,8 +3,12 @@ package com.tangem.features.onramp.swap.availablepairs import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier +import com.arkivanov.decompose.router.slot.SlotNavigation import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.ui.onrampSwapTokenList @@ -21,6 +25,10 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( private val model: AvailableSwapPairsModel = getOrCreateModel(params) + override val bottomSheetNavigation: SlotNavigation get() = model.bottomSheetNavigation + override val addToPortfolioManager: AddToPortfolioManager? get() = model.addToPortfolioManager + override val addToPortfolioCallback: AddToPortfolioComponent.Callback get() = model.addToPortfolioCallback + override val uiState: StateFlow get() = model.state diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt index 2aecafa4e4..8d9b34c5b8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt @@ -228,6 +228,13 @@ internal class SwapMarketsListBatchFlowManager( } } + fun getTokenMarketById(id: CryptoCurrency.RawID): TokenMarket? { + return batchFlow.state.value.data + .asSequence() + .flatMap { it.data } + .firstOrNull { it.id == id } + } + fun getBatchKeysByItemIds(ids: List): Set { val currentData = batchFlow.state.value.data diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt new file mode 100644 index 0000000000..559ef6eb09 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt @@ -0,0 +1,7 @@ +package com.tangem.features.onramp.swap.availablepairs.model + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal data object AddToPortfolioRoute : Route \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index 5466084880..7867772800 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -9,28 +9,36 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.GetTokenListUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.domain.GetAvailablePairsUseCase import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.SwapPairLeast +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager import com.tangem.features.onramp.impl.R +import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent import com.tangem.features.swap.SwapFeatureToggles import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer @@ -48,9 +56,17 @@ import com.tangem.features.onramp.tokenlist.entity.transformer.* import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.lib.crypto.BlockchainUtils +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.coroutines.saveIn import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -71,7 +87,10 @@ internal class AvailableSwapPairsModel @Inject constructor( private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val accountsFeatureToggles: AccountsFeatureToggles, private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, - private val swapFeatureToggles: SwapFeatureToggles, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + private val excludedBlockchains: ExcludedBlockchains, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + swapFeatureToggles: SwapFeatureToggles, getWalletsUseCase: GetWalletsUseCase, ) : Model() { @@ -79,6 +98,17 @@ internal class AvailableSwapPairsModel @Inject constructor( private val params: AvailableSwapPairsComponent.Params = paramsContainer.require() private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } + private val allUserWallets = getWalletsUseCase.invokeSync() + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + var addToPortfolioManager: AddToPortfolioManager? = null + val addToPortfolioCallback: AddToPortfolioComponent.Callback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + override fun onSuccess(addedToken: CryptoCurrency) { + onTokenAddedToPortfolio(addedToken) + } + } + private val addToPortfolioJobHolder = JobHolder() private val tokenListFlow = getTokenListUseCaseFlow() private val accountListFlow = getAccountListUseCaseFlow() @@ -86,6 +116,7 @@ internal class AvailableSwapPairsModel @Inject constructor( private val selectedAppCurrencyFlow: StateFlow = getSelectedAppCurrencyUseCase.invokeOrDefault() .stateIn(scope = modelScope, started = SharingStarted.Eagerly, initialValue = AppCurrency.Default) + private val refreshPairsTrigger = MutableSharedFlow() private val searchQueryStateForMarkets = MutableStateFlow("") private val visibleMarketItemIds = MutableStateFlow>(emptyList()) @@ -374,8 +405,12 @@ internal class AvailableSwapPairsModel @Inject constructor( private fun subscribeOnAvailablePairsUpdates() { modelScope.launch { - params.selectedStatus - .filterNotNull() + combine( + params.selectedStatus.filterNotNull(), + refreshPairsTrigger + .onEach { availablePairsByNetworkFlow.value = emptyMap() } + .onStart { emit(Unit) }, + ) { status, _ -> status } .collectLatest { selectedStatus -> val networkInfo = selectedStatus.toLeastTokenInfo() @@ -542,7 +577,7 @@ internal class AvailableSwapPairsModel @Inject constructor( else -> SwapMarketState.Content( items = uiItems, loadMore = { searchMarketsListManager.loadMore() }, - onItemClick = {}, + onItemClick = { item -> addToPortfolioItem(item) }, visibleIdsChanged = { visibleMarketItemIds.value = it }, total = total ?: uiItems.size, ) @@ -556,6 +591,66 @@ internal class AvailableSwapPairsModel @Inject constructor( .launchIn(modelScope) } + private fun onTokenAddedToPortfolio(addedToken: CryptoCurrency) { + modelScope.launch { + bottomSheetNavigation.dismiss() + + // Trigger re-fetch of available pairs (clears cache + re-enters collectLatest) + refreshPairsTrigger.emit(Unit) + + // Wait for the added token status to become Loaded + val addedTokenStatus = getAccountCurrencyStatusUseCase(params.userWalletId, addedToken) + .firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded } + ?.status + ?: return@launch + + // Convert to TokenItemState and trigger token selection → navigates to swap + val converter = OnrampTokenItemStateConverterFactory.createAvailableItemConverter( + appCurrency = selectedAppCurrencyFlow.value, + onItemClick = params.onTokenClick, + ) + params.onTokenClick(converter.convert(addedTokenStatus), addedTokenStatus) + } + } + + private fun addToPortfolioItem(item: MarketsListItemUM) { + modelScope.launch { + val tokenMarket = searchMarketsListManager.getTokenMarketById(item.id) ?: return@launch + + val param = tokenMarket.toSerializableParam() + val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot } + + val networks = tokenMarket.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + blockchainId = network.networkId, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = hasOnlyHotWallets, + ) + }?.map { network -> + TokenMarketInfo.Network( + networkId = network.networkId, + isExchangeable = false, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + }.orEmpty() + + addToPortfolioManager = addToPortfolioManagerFactory + .create( + scope = modelScope, + token = param, + analyticsParams = null, + ).apply { + setTokenNetworks(networks) + } + + addToPortfolioManager?.state + ?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd } + ?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) } + }.saveIn(addToPortfolioJobHolder) + } + private fun subscribeOnVisibleMarketItems() { modelScope.launch { visibleMarketItemIds.mapNotNull { rawIds ->