From b3138bf5605683195242cb6ce75291efe0d21671 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 7 Nov 2025 14:39:04 +0700 Subject: [PATCH] Updated on 2026-08-14 --- .../converters/HotCryptoCurrencyConverter.kt | 1 - features/onramp/impl/build.gradle.kts | 1 + .../hottokens/DefaultHotCryptoComponent.kt | 161 ++++++++++++++++ .../onramp/hottokens/model/HotCryptoModel.kt | 182 ++++++++++++++++-- .../model/HotCryptoPortfolioDataLoader.kt | 84 ++++++++ .../portfolio/OnrampAddTokenComponent.kt | 59 ++++++ .../di/OnrampAddToPortfolioModelModule.kt | 6 + .../portfolio/entity/OnrampAddTokenRoute.kt | 16 ++ .../entity/OnrampAddTokenUiBuilder.kt | 103 ++++++++++ .../model/OnrampAddToPortfolioModel.kt | 29 +-- .../portfolio/model/OnrampAddTokenModel.kt | 102 ++++++++++ 11 files changed, 707 insertions(+), 37 deletions(-) create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoPortfolioDataLoader.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/OnrampAddTokenComponent.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenRoute.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt create mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt b/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt index 539de8d55c..256cc0c439 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/converters/HotCryptoCurrencyConverter.kt @@ -75,7 +75,6 @@ internal class HotCryptoCurrencyConverter( } } - // TODO account private fun createNetwork(networkId: String): Network? { val blockchain = Blockchain.fromNetworkId(networkId) ?: return null diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index e1d7b97469..44736bee00 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -13,6 +13,7 @@ android { dependencies { /** Project - API */ + implementation(projects.features.account.api) implementation(projects.features.onramp.api) implementation(projects.features.swap.domain) implementation(projects.features.swap.domain.api) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt index f284662276..be49d922a7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt @@ -1,21 +1,45 @@ package com.tangem.features.onramp.hottokens +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp 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.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.decompose.router.stack.* 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.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.features.account.PortfolioSelectorComponent import com.tangem.features.onramp.hottokens.model.HotCryptoModel import com.tangem.features.onramp.hottokens.portfolio.OnrampAddToPortfolioComponent +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddToPortfolioBSConfig +import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddTokenRoute import com.tangem.features.onramp.hottokens.ui.HotCrypto +import com.tangem.features.onramp.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -24,10 +48,40 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: HotCryptoComponent.Params, private val onrampAddToPortfolioComponentFactory: OnrampAddToPortfolioComponent.Factory, + private val accountsFeatureToggles: AccountsFeatureToggles, + portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, + addTokenComponentFactory: OnrampAddTokenComponent.Factory, ) : HotCryptoComponent, AppComponentContext by context { private val model: HotCryptoModel = getOrCreateModel(params) + private val portfolioSelectorComponent: PortfolioSelectorComponent? by lazy { + if (accountsFeatureToggles.isFeatureEnabled) { + portfolioSelectorComponentFactory.create( + context = child("portfolioSelectorComponent"), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = model.portfolioFetcher!!, + controller = model.portfolioSelectorController, + ), + ) + } else { + null + } + } + private val addTokenComponent: OnrampAddTokenComponent? by lazy { + if (accountsFeatureToggles.isFeatureEnabled) { + addTokenComponentFactory.create( + context = child("addTokenComponent"), + params = OnrampAddTokenComponent.Params( + callbacks = model, + tokenToAdd = model.hotCryptoToAddDataFlow, + ), + ) + } else { + null + } + } + private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, serializer = null, @@ -35,6 +89,15 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( childFactory = ::bottomSheetChild, ) + private val childStack = childStack( + key = "addHotCryptoToPortfolioStack", + handleBackButton = true, + source = model.bottomSheetNavigationV2, + serializer = OnrampAddTokenRoute.serializer(), + initialConfiguration = OnrampAddTokenRoute.Empty, + childFactory = { config, _ -> contentChild(config) }, + ) + @Composable override fun Content(modifier: Modifier) { val state = model.state.collectAsStateWithLifecycle() @@ -43,6 +106,98 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( HotCrypto(state, modifier) bottomSheet.child?.instance?.BottomSheet() + if (accountsFeatureToggles.isFeatureEnabled) { + AddHotCryptoBottomSheet() + } + } + + @Composable + private fun AddHotCryptoBottomSheet() { + val stack by childStack.subscribeAsState() + val contentStack = remember { mutableStateOf(stack) } + val currentRoute = stack.active.configuration + val isNotEmpty = currentRoute != OnrampAddTokenRoute.Empty + if (isNotEmpty) { + contentStack.value = stack + } + + fun dismiss() = model.bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.Empty) + fun onBack() = if (childStack.backStack.isNotEmpty()) model.bottomSheetNavigationV2.pop() else dismiss() + + TangemModalBottomSheet( + scrollableContent = false, + onBack = ::onBack, + config = TangemBottomSheetConfig( + isShown = isNotEmpty, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + title = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + BottomSheetTitle( + stack = stack, + onBackClick = ::onBack, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + content = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + val paddingModifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ) + val scrollableContent = when (stack.active.configuration) { + OnrampAddTokenRoute.PortfolioSelector -> false + OnrampAddTokenRoute.AddToken, + OnrampAddTokenRoute.Empty, + -> true + } + if (scrollableContent) { + Column( + modifier = paddingModifier.verticalScroll(rememberScrollState()), + ) { + stack.active.instance.Content(modifier = Modifier) + } + } else { + stack.active.instance.Content(modifier = paddingModifier) + } + } + }, + ) + } + + @Composable + private fun BottomSheetTitle( + stack: ChildStack, + onBackClick: (() -> Unit), + modifier: Modifier = Modifier, + ) { + val title: TextReference = when (stack.active.configuration) { + OnrampAddTokenRoute.AddToken -> resourceReference(R.string.common_add_token) + OnrampAddTokenRoute.Empty -> TextReference.EMPTY + OnrampAddTokenRoute.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent) + .title.collectAsStateWithLifecycle().value + } + val startIconRes: Int? + val endIconRes: Int? + if (stack.backStack.isNotEmpty()) { + startIconRes = R.drawable.ic_back_24 + endIconRes = null + } else { + startIconRes = null + endIconRes = R.drawable.ic_close_24 + } + TangemModalBottomSheetTitle( + modifier = modifier, + title = title, + startIconRes = startIconRes, + endIconRes = endIconRes, + onStartClick = onBackClick, + onEndClick = onBackClick, + ) } private fun bottomSheetChild( @@ -65,6 +220,12 @@ internal class DefaultHotCryptoComponent @AssistedInject constructor( } } + private fun contentChild(config: OnrampAddTokenRoute): ComposableContentComponent = when (config) { + OnrampAddTokenRoute.AddToken -> addTokenComponent!! + OnrampAddTokenRoute.PortfolioSelector -> portfolioSelectorComponent!! + OnrampAddTokenRoute.Empty -> ComposableContentComponent.EMPTY + } + @AssistedFactory interface Factory : HotCryptoComponent.Factory { override fun create(context: AppComponentContext, params: HotCryptoComponent.Params): DefaultHotCryptoComponent diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt index f4a8cdc2eb..3572d99f56 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt @@ -4,26 +4,43 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.popToFirst +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.onramp.GetHotCryptoUseCase import com.tangem.domain.onramp.model.HotCryptoCurrency import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.account.PortfolioSelectorController import com.tangem.features.onramp.hottokens.HotCryptoComponent import com.tangem.features.onramp.hottokens.converter.HotTokenItemStateConverter import com.tangem.features.onramp.hottokens.entity.HotCryptoUM +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent.AddHotCryptoData import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddToPortfolioBSConfig +import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddTokenRoute import com.tangem.features.onramp.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber @@ -40,23 +57,71 @@ import javax.inject.Inject * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") @ModelScoped internal class HotCryptoModel @Inject constructor( paramsContainer: ParamsContainer, - getHotCryptoUseCase: GetHotCryptoUseCase, - getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getHotCryptoUseCase: GetHotCryptoUseCase, + private val callbackDelegate: HotCryptoModelCallbackDelegate, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase, override val dispatchers: CoroutineDispatcherProvider, -) : Model() { + private val hotCryptoPortfolioDataLoader: HotCryptoPortfolioDataLoader, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + val portfolioSelectorController: PortfolioSelectorController, + private val portfolioFetcherFactory: PortfolioFetcher.Factory, +) : Model(), OnrampAddTokenComponent.Callbacks by callbackDelegate { val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val portfolioFetcher: PortfolioFetcher? + val bottomSheetNavigationV2 = StackNavigation() + private val addHotCryptoJob = JobHolder() + val hotCryptoToAddDataFlow: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val state: StateFlow get() = _state private val _state = MutableStateFlow(value = HotCryptoUM(items = persistentListOf())) private val params: HotCryptoComponent.Params = paramsContainer.require() init { + if (accountsFeatureToggles.isFeatureEnabled) { + portfolioFetcher = portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.Wallet(params.userWalletId), + scope = modelScope, + ) + combineData() + } else { + portfolioFetcher = null + combineDataOld() + } + } + + private fun combineData() { + combine( + flow = hotCryptoPortfolioDataLoader.loadPortfolioData(params.userWalletId), + flow2 = isAccountsModeEnabledUseCase.invoke(), + transform = { data, isAccountMode -> + HotTokenItemStateConverter( + appCurrency = data.appCurrency, + onItemClick = { tokenItemState, hotCryptoCurrency -> + startAddTokenFlow(currency = hotCryptoCurrency, hotCryptoPortfolioData = data) + }, + ) + .convertList(data.allHotCrypto) + .map(TokensListItemUM::Token) + }, + ) + .onEach { items -> _state.update { HotCryptoUM(items = it.buildItems(items)) } } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun combineDataOld() { combine( flow = getSelectedAppCurrencyUseCase().map { it.getOrElse { AppCurrency.Default } }, flow2 = getHotCryptoUseCase(params.userWalletId), @@ -66,22 +131,19 @@ internal class HotCryptoModel @Inject constructor( .map(TokensListItemUM::Token) } .onEach { items -> - _state.update { - HotCryptoUM( - items = buildList { - if (items.isNotEmpty()) { - add(it.getOrCreateGroupTitle()) - } - - addAll(items) - } - .toImmutableList(), - ) - } + _state.update { HotCryptoUM(items = it.buildItems(items)) } } .launchIn(modelScope) } + private fun HotCryptoUM.buildItems(items: List): ImmutableList = + buildList { + if (items.isNotEmpty()) { + add(getOrCreateGroupTitle()) + } + addAll(items) + }.toImmutableList() + private fun HotCryptoUM.getOrCreateGroupTitle(): TokensListItemUM { return items.firstOrNull() .takeIf { it is TokensListItemUM.GroupTitle } @@ -101,6 +163,69 @@ internal class HotCryptoModel @Inject constructor( ) } + private fun startAddTokenFlow(currency: HotCryptoCurrency, hotCryptoPortfolioData: HotCryptoPortfolioData) { + hotCryptoToAddDataFlow.resetReplayCache() + fun closeNavigationFlow() = bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.Empty) + channelFlow { + val userWallet = hotCryptoPortfolioData.wallet.userWallet + val isSingleAccount = hotCryptoPortfolioData.wallet.accounts.size == 1 + + if (isSingleAccount) { + val account = hotCryptoPortfolioData.wallet.accounts.first().account + val tokenToAdd = AddHotCryptoData( + cryptoCurrency = currency.cryptoCurrency, + userWallet = userWallet, + account = account, + availableMorePortfolio = false, + ) + hotCryptoToAddDataFlow.emit(tokenToAdd) + bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.AddToken) + } else { + setupPortfolioSelector(currency, hotCryptoPortfolioData) + bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.PortfolioSelector) + + val tokenToAddStateFlow = portfolioSelectorController + .selectedAccountWithData(requireNotNull(portfolioFetcher)) + .filterNotNull() + .map { (_, selectedAccount) -> + AddHotCryptoData( + cryptoCurrency = currency.cryptoCurrency, + userWallet = userWallet, + account = selectedAccount, + availableMorePortfolio = true, + ) + } + .shareIn(this, started = SharingStarted.Eagerly) + + val firstTokenToAdd = tokenToAddStateFlow.first() + hotCryptoToAddDataFlow.emit(firstTokenToAdd) + bottomSheetNavigationV2.replaceAll(OnrampAddTokenRoute.AddToken) + + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { bottomSheetNavigationV2.pushNew(OnrampAddTokenRoute.PortfolioSelector) } + .launchIn(this) + + tokenToAddStateFlow + .onEach { newTokenToAdd -> hotCryptoToAddDataFlow.emit(newTokenToAdd) } + .onEach { bottomSheetNavigationV2.popToFirst() } + .launchIn(this) + } + + val addedToken = callbackDelegate.onTokenAdded + .receiveAsFlow() + .first() + params.onTokenClick(addedToken) + closeNavigationFlow() + channel.close() + } + .catch { + Timber.e(it) + closeNavigationFlow() + } + .launchIn(modelScope) + .saveIn(addHotCryptoJob) + } + private fun onSuccessAdding(id: CryptoCurrency.ID) { modelScope.launch { getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync( @@ -114,4 +239,31 @@ internal class HotCryptoModel @Inject constructor( .onLeft { Timber.d("Unable to get CryptoCurrencyStatus[$id]: $it") } } } + + private fun setupPortfolioSelector(hotCrypto: HotCryptoCurrency, hotCryptoPortfolioData: HotCryptoPortfolioData) { + portfolioSelectorController.selectAccount(null) + portfolioSelectorController.isEnabled.value = isEnabled@{ _, accountStatus -> + val isNotAddedHotCrypto = hotCryptoPortfolioData.wallet.accounts + .find { it.account.accountId == accountStatus.accountId } + ?.addedHotCrypto + ?.none { it.currency.id.rawCurrencyId == hotCrypto.cryptoCurrency.id.rawCurrencyId } + ?: false + return@isEnabled isNotAddedHotCrypto + } + } +} + +@ModelScoped +internal class HotCryptoModelCallbackDelegate @Inject constructor() : OnrampAddTokenComponent.Callbacks { + + val onChangePortfolioClick = Channel() + val onTokenAdded = Channel() + + override fun onChangePortfolioClick() { + onChangePortfolioClick.trySend(Unit) + } + + override fun onTokenAdded(status: CryptoCurrencyStatus) { + onTokenAdded.trySend(status) + } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoPortfolioDataLoader.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoPortfolioDataLoader.kt new file mode 100644 index 0000000000..b47d4f39ee --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoPortfolioDataLoader.kt @@ -0,0 +1,84 @@ +package com.tangem.features.onramp.hottokens.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatuses +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.GetHotCryptoUseCase +import com.tangem.domain.onramp.model.HotCryptoCurrency +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapNotNull +import javax.inject.Inject + +@ModelScoped +internal class HotCryptoPortfolioDataLoader @Inject constructor( + private val appCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getHotCryptoUseCase: GetHotCryptoUseCase, + private val accountListSupplier: SingleAccountStatusListSupplier, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + +) { + + fun loadPortfolioData(userWalletId: UserWalletId): Flow = combine( + flow = appCurrencyUseCase.invokeOrDefault().distinctUntilChanged(), + flow2 = getHotCryptoUseCase(userWalletId).distinctUntilChanged(), + flow3 = accountListSupplier.invoke(userWalletId).distinctUntilChanged(), + flow4 = getUserWalletUseCase.invokeFlow(userWalletId).mapNotNull { it.getOrNull() }, + ) { appCurrency, allHotCrypto, walletAccounts, userWallet -> + + val hotCryptoCurrencies = allHotCrypto.map { hotCrypto -> hotCrypto.cryptoCurrency } + val mapOfAddedCurrencies: AccountCryptoCurrencyStatuses = getAccountCurrencyStatusUseCase + .invokeSync(userWalletId, hotCryptoCurrencies) + .getOrNull() + ?: emptyMap() + val accountsWithHotCrypto = walletAccounts.accountStatuses.map { + val account: AccountStatus.CryptoPortfolio = when (it) { + is AccountStatus.CryptoPortfolio -> it + } + val addedHotCrypto = mapOfAddedCurrencies[account.account] ?: listOf() + HotCryptoPortfolioData.Account( + account = account, + addedHotCrypto = addedHotCrypto, + ) + } + + HotCryptoPortfolioData( + allHotCrypto = allHotCrypto, + appCurrency = appCurrency, + wallet = HotCryptoPortfolioData.Wallet( + userWallet = userWallet, + statusList = walletAccounts, + accounts = accountsWithHotCrypto, + ), + ) + } +} + +internal data class HotCryptoPortfolioData( + val allHotCrypto: List, + val wallet: Wallet, + val appCurrency: AppCurrency, +) { + + data class Wallet( + val userWallet: UserWallet, + val statusList: AccountStatusList, + val accounts: List, + ) + + data class Account( + val account: AccountStatus.CryptoPortfolio, + val addedHotCrypto: List, + ) +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/OnrampAddTokenComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/OnrampAddTokenComponent.kt new file mode 100644 index 0000000000..2e79cd6137 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/OnrampAddTokenComponent.kt @@ -0,0 +1,59 @@ +package com.tangem.features.onramp.hottokens.portfolio + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.addtoken.AddTokenContent +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.onramp.hottokens.portfolio.model.OnrampAddTokenModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class OnrampAddTokenComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: OnrampAddTokenModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val um = state.value ?: return + AddTokenContent( + modifier = modifier, + state = um, + ) + } + + data class Params( + val tokenToAdd: Flow, + val callbacks: Callbacks, + ) + + data class AddHotCryptoData( + val cryptoCurrency: CryptoCurrency, + val userWallet: UserWallet, + val account: AccountStatus, + val availableMorePortfolio: Boolean, + ) + + interface Callbacks { + fun onChangePortfolioClick() + fun onTokenAdded(status: CryptoCurrencyStatus) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): OnrampAddTokenComponent + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/di/OnrampAddToPortfolioModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/di/OnrampAddToPortfolioModelModule.kt index 4db40ffeca..21019296d6 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/di/OnrampAddToPortfolioModelModule.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/di/OnrampAddToPortfolioModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.onramp.hottokens.portfolio.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.onramp.hottokens.portfolio.model.OnrampAddToPortfolioModel +import com.tangem.features.onramp.hottokens.portfolio.model.OnrampAddTokenModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +18,9 @@ internal interface OnrampAddToPortfolioModelModule { @IntoMap @ClassKey(OnrampAddToPortfolioModel::class) fun bindOnrampSelectCountryModel(model: OnrampAddToPortfolioModel): Model + + @Binds + @IntoMap + @ClassKey(OnrampAddTokenModel::class) + fun bindOnrampAddTokenModel(model: OnrampAddTokenModel): Model } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenRoute.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenRoute.kt new file mode 100644 index 0000000000..2dbe46e210 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenRoute.kt @@ -0,0 +1,16 @@ +package com.tangem.features.onramp.hottokens.portfolio.entity + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface OnrampAddTokenRoute { + + @Serializable + data object Empty : OnrampAddTokenRoute + + @Serializable + data object PortfolioSelector : OnrampAddTokenRoute + + @Serializable + data object AddToken : OnrampAddTokenRoute +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt new file mode 100644 index 0000000000..48def24fab --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt @@ -0,0 +1,103 @@ +package com.tangem.features.onramp.hottokens.portfolio.entity + +import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.PortfolioSelectUM +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.iconResId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.models.account.AccountStatus +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent.AddHotCryptoData +import com.tangem.features.onramp.impl.R +import javax.inject.Inject + +@ModelScoped +internal class OnrampAddTokenUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, +) { + private val params = paramsContainer.require() + + private fun createNetwork(tokenToAdd: AddHotCryptoData): AddTokenUM.Network { + return AddTokenUM.Network( + icon = tokenToAdd.cryptoCurrency.network.iconResId, + name = stringReference(tokenToAdd.cryptoCurrency.network.name), + editable = false, + onClick = {}, + ) + } + + private suspend fun createPortfolio(tokenToAdd: AddHotCryptoData): PortfolioSelectUM { + val accountIcon: CryptoPortfolioIconUM? + val portfolioName: TextReference + val isAccountMode = isAccountsModeEnabledUseCase.invokeSync() + when (isAccountMode) { + false -> { + accountIcon = null + portfolioName = stringReference(tokenToAdd.userWallet.name) + } + true -> { + val accountStatus = tokenToAdd.account + portfolioName = accountStatus.account.accountName.toUM().value + accountIcon = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.account.icon.toUM() + } + } + } + return PortfolioSelectUM( + icon = accountIcon, + name = portfolioName, + isAccountMode = isAccountMode, + isMultiChoice = tokenToAdd.availableMorePortfolio, + onClick = { params.callbacks.onChangePortfolioClick() }, + ) + } + + suspend fun updateContent( + isTangemIconVisible: Boolean, + onConfirmClick: () -> Unit, + tokenToAdd: AddHotCryptoData, + ): AddTokenUM { + val button = AddTokenUM.Button( + showProgress = false, + isTangemIconVisible = isTangemIconVisible, + text = resourceReference(R.string.common_add), + onConfirmClick = onConfirmClick, + isEnabled = true, + ) + val networkUM = createNetwork(tokenToAdd) + val portfolioUM = createPortfolio(tokenToAdd) + val currency = tokenToAdd.cryptoCurrency + val tokenToAdd = TokenItemState.Content( + id = currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(currency), + titleState = TokenItemState.TitleState.Content(stringReference(currency.name)), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return AddTokenUM( + tokenToAdd = tokenToAdd, + network = networkUM, + portfolio = portfolioUM, + button = button, + ) + } + + companion object { + + fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy( + button = this.button.copy(showProgress = showProgress), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt index bfcf0b1a5d..e11a0780e7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt @@ -1,11 +1,9 @@ package com.tangem.features.onramp.hottokens.portfolio.model import arrow.core.getOrElse -import arrow.core.left import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase @@ -37,7 +35,6 @@ internal class OnrampAddToPortfolioModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val derivePublicKeysUseCase: DerivePublicKeysUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, - private val accountsFeatureToggles: AccountsFeatureToggles, private val getUserWalletUseCase: GetUserWalletUseCase, ) : Model() { @@ -75,26 +72,16 @@ internal class OnrampAddToPortfolioModel @Inject constructor( private fun onAddClick() { modelScope.launch { changeAddButtonProgressStatus(isProgress = true) + derivePublicKeysUseCase(params.userWalletId, listOfNotNull(params.cryptoCurrency)).getOrElse { + Timber.e("Failed to derive public keys: $it") - if (accountsFeatureToggles.isFeatureEnabled) { - // saveCryptoCurrenciesUseCase( - // accountId = params.accountId, - // add = params.cryptoCurrency, - // ) - // TODO account - IllegalStateException("Not implemented yet").left() - } else { - derivePublicKeysUseCase(params.userWalletId, listOfNotNull(params.cryptoCurrency)).getOrElse { - Timber.e("Failed to derive public keys: $it") - - changeAddButtonProgressStatus(isProgress = false) - } - - addCryptoCurrenciesUseCase( - userWalletId = params.userWalletId, - currency = params.cryptoCurrency, - ) + changeAddButtonProgressStatus(isProgress = false) } + + addCryptoCurrenciesUseCase( + userWalletId = params.userWalletId, + currency = params.cryptoCurrency, + ) .onRight { params.onSuccessAdding(params.cryptoCurrency.id) } .onLeft { changeAddButtonProgressStatus(isProgress = false) } } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt new file mode 100644 index 0000000000..4d76738d34 --- /dev/null +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt @@ -0,0 +1,102 @@ +package com.tangem.features.onramp.hottokens.portfolio.model + +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.BackendId +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent +import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent.AddHotCryptoData +import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddTokenUiBuilder +import com.tangem.features.onramp.hottokens.portfolio.entity.OnrampAddTokenUiBuilder.Companion.toggleProgress +import com.tangem.features.onramp.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class OnrampAddTokenModel @Inject constructor( + paramsContainer: ParamsContainer, + private val uiBuilder: OnrampAddTokenUiBuilder, + private val messageSender: UiMessageSender, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) : Model() { + + private val params = paramsContainer.require() + private val addTokenJob = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow(value = null) + + init { + params.tokenToAdd + .distinctUntilChanged() + .mapLatest { tokenToAdd: AddHotCryptoData -> + addTokenJob.cancel() + val backendId = tokenToAdd.cryptoCurrency.network.backendId + val userWalletId = tokenToAdd.account.accountId.userWalletId + val isTangemIconVisible = needColdWalletInteraction(userWalletId, backendId) + uiBuilder.updateContent( + tokenToAdd = tokenToAdd, + isTangemIconVisible = isTangemIconVisible, + onConfirmClick = { onAddClick(tokenToAdd).saveIn(addTokenJob) }, + ) + } + .onEach { newUI -> uiState.value = newUI } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onAddClick(tokenToAdd: AddHotCryptoData) = modelScope.launch(dispatchers.default) { + val um = uiState.value ?: return@launch + uiState.value = um.toggleProgress(true) + + val cryptoCurrency = tokenToAdd.cryptoCurrency + val accountId = tokenToAdd.account.accountId + manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) + .onLeft { + processError(error = it) + uiState.value = um.toggleProgress(false) + return@launch + } + + val status = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = accountId.userWalletId, + currencyId = cryptoCurrency.id, + network = cryptoCurrency.network, + ).getOrNull() + if (status == null) { + processError(error = null) + } else { + params.callbacks.onTokenAdded(status.status) + } + uiState.value = um.toggleProgress(false) + } + + private suspend fun needColdWalletInteraction(walletId: UserWalletId, backendId: BackendId): Boolean = + coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = walletId, + networksWithDerivationPath = mapOf(backendId to null), + ) + + private fun processError(error: Throwable?) { + val message = error?.message?.let { stringReference(it) } + ?: resourceReference(R.string.common_something_went_wrong) + messageSender.send(ToastMessage(message = message)) + } +} \ No newline at end of file