Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-07 14:39:04 +07:00
parent 0edc7eb8a8
commit b3138bf560
11 changed files with 707 additions and 37 deletions

View file

@ -75,7 +75,6 @@ internal class HotCryptoCurrencyConverter(
}
}
// TODO account
private fun createNetwork(networkId: String): Network? {
val blockchain = Blockchain.fromNetworkId(networkId) ?: return null

View file

@ -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)

View file

@ -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<TangemBottomSheetConfigContent.Empty>(
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<OnrampAddTokenRoute, ComposableContentComponent>,
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

View file

@ -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<OnrampAddToPortfolioBSConfig> = SlotNavigation()
val portfolioFetcher: PortfolioFetcher?
val bottomSheetNavigationV2 = StackNavigation<OnrampAddTokenRoute>()
private val addHotCryptoJob = JobHolder()
val hotCryptoToAddDataFlow: MutableSharedFlow<AddHotCryptoData> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val state: StateFlow<HotCryptoUM> 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<TokensListItemUM.Token>): ImmutableList<TokensListItemUM> =
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<Unit> {
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<Unit>()
val onTokenAdded = Channel<CryptoCurrencyStatus>()
override fun onChangePortfolioClick() {
onChangePortfolioClick.trySend(Unit)
}
override fun onTokenAdded(status: CryptoCurrencyStatus) {
onTokenAdded.trySend(status)
}
}

View file

@ -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<HotCryptoPortfolioData> = 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<HotCryptoCurrency>,
val wallet: Wallet,
val appCurrency: AppCurrency,
) {
data class Wallet(
val userWallet: UserWallet,
val statusList: AccountStatusList,
val accounts: List<Account>,
)
data class Account(
val account: AccountStatus.CryptoPortfolio,
val addedHotCrypto: List<CryptoCurrencyStatus>,
)
}

View file

@ -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<AddHotCryptoData>,
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<Params, OnrampAddTokenComponent> {
override fun create(context: AppComponentContext, params: Params): OnrampAddTokenComponent
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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<OnrampAddTokenComponent.Params>()
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),
)
}
}

View file

@ -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) }
}

View file

@ -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<OnrampAddTokenComponent.Params>()
private val addTokenJob = JobHolder()
val uiState: StateFlow<AddTokenUM?>
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))
}
}