Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-24 09:59:56 +02:00
parent 3da4771d66
commit 3baed1e815
57 changed files with 1797 additions and 370 deletions

View file

@ -9,7 +9,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.features.commonfeatures.impl.R
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
@ -21,6 +20,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
@Composable
@ -63,7 +63,9 @@ internal fun AddToPortfolioBottomSheet(
bottom = 16.dp,
)
val isScrollableContent = when (animatedStack.active.configuration) {
AddToPortfolioRoutes.PortfolioSelector -> false
AddToPortfolioRoutes.PortfolioSelector,
AddToPortfolioRoutes.UserPortfolio,
-> false
AddToPortfolioRoutes.AddToken,
AddToPortfolioRoutes.Empty,
is AddToPortfolioRoutes.NetworkSelector,
@ -95,6 +97,7 @@ private fun AddToPortfolioBottomSheetTitle(
AddToPortfolioRoutes.Empty -> TextReference.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network)
AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token)
AddToPortfolioRoutes.UserPortfolio -> resourceReference(R.string.markets_portfolio_block_title)
AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent)
.title.collectAsStateWithLifecycle().value
}

View file

@ -11,14 +11,17 @@ 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.ComposableContentComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@Suppress("LongParameterList")
internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddToPortfolioComponent.Params,
@ -26,6 +29,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
addTokenComponentFactory: AddTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory,
private val userPortfolioComponentFactory: UserPortfolioComponent.Factory,
) : AppComponentContext by context, AddToPortfolioComponent {
private val model: AddToPortfolioModel = getOrCreateModel(params)
@ -91,6 +95,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
AddToPortfolioRoutes.UserPortfolio -> createUserPortfolioComponent(componentContext)
is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create(
context = childByContext(componentContext),
params = ChooseNetworkComponent.Params(
@ -100,6 +105,19 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
)
}
private fun createUserPortfolioComponent(componentContext: ComponentContext): ComposableContentComponent {
return when (model.addToPortfolioManager.settings.launchMode) {
AddToPortfolioManager.LaunchMode.DirectAdd -> ComposableContentComponent.EMPTY
is AddToPortfolioManager.LaunchMode.ViaUserPortfolio -> userPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = UserPortfolioComponent.Params(
uiState = model.userPortfolioStateController.uiState,
callbacks = model,
),
)
}
}
@AssistedFactory
interface Factory : AddToPortfolioComponent.Factory {
override fun create(

View file

@ -79,6 +79,7 @@ internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject con
AddToPortfolioRoutes.TokenActions -> ComposableContentComponent.EMPTY
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> ComposableContentComponent.EMPTY
AddToPortfolioRoutes.UserPortfolio -> ComposableContentComponent.EMPTY
}
@AssistedFactory

View file

@ -6,6 +6,8 @@ import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPrese
import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioPreselectedDataComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.DefaultAddToPortfolioManager
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.DefaultUserPortfolioComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -25,4 +27,9 @@ internal interface AddToPortfolioComponentModule {
fun bindAddToPortfolioPreselectedDataComponent(
factory: DefaultAddToPortfolioPreselectedDataComponent.Factory,
): AddToPortfolioPreselectedDataComponent.Factory
@Binds
fun bindUserPortfolioComponentFactory(
factory: DefaultUserPortfolioComponent.Factory,
): UserPortfolioComponent.Factory
}

View file

@ -7,6 +7,7 @@ import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfol
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -41,4 +42,9 @@ internal interface AddToPortfolioModelModule {
@IntoMap
@ClassKey(ChooseNetworkModel::class)
fun chooseNetworkModel(model: ChooseNetworkModel): Model
@Binds
@IntoMap
@ClassKey(UserPortfolioModel::class)
fun userPortfolioModel(model: UserPortfolioModel): Model
}

View file

@ -0,0 +1,114 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import arrow.core.getOrElse
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet
import javax.inject.Inject
internal class AddToPortfolioInitialSelectionResolver @Inject constructor(
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase,
) {
suspend fun resolve(
availableToAddData: AvailableToAddData,
orderedNetworks: List<TokenMarketInfo.Network>,
selectedWallet: UserWallet?,
tokenParams: TokenMarketParams,
accountToAdd: AvailableToAddAccount? = null,
): InitialSelection? {
if (availableToAddData.availableToAddWallets.isEmpty()) return null
val fallbackNetwork = orderedNetworks.firstOrNull() ?: return null
val walletOrder = orderedWallets(availableToAddData, selectedWallet)
if (accountToAdd != null) {
val ownerWallet = walletOrder.firstOrNull { entry ->
entry.availableToAddAccounts.values.any { it === accountToAdd }
} ?: walletOrder.first()
val network = pickAddableNetwork(
userWallet = ownerWallet.userWallet,
account = accountToAdd,
orderedNetworks = orderedNetworks,
tokenParams = tokenParams,
)
?: fallbackNetwork
return InitialSelection(userWallet = ownerWallet.userWallet, account = accountToAdd, network = network)
}
for (walletEntry in walletOrder) {
val account = pickAvailableAccount(walletEntry) ?: continue
val network = pickAddableNetwork(
userWallet = walletEntry.userWallet,
account = account,
orderedNetworks = orderedNetworks,
tokenParams = tokenParams,
) ?: continue
return InitialSelection(walletEntry.userWallet, account, network)
}
val fallbackWallet = walletOrder.first()
val fallbackAccount = pickFallbackAccount(fallbackWallet) ?: return null
return InitialSelection(fallbackWallet.userWallet, fallbackAccount, fallbackNetwork)
}
private fun orderedWallets(data: AvailableToAddData, selectedWallet: UserWallet?): List<AvailableToAddWallet> {
val preferred = selectedWallet?.walletId?.let { data.availableToAddWallets[it] }
return buildList {
preferred?.let(::add)
data.availableToAddWallets.values.forEach { entry ->
if (entry !== preferred) add(entry)
}
}
}
private fun pickAvailableAccount(walletEntry: AvailableToAddWallet): AvailableToAddAccount? {
val mainId = AccountId.forMainCryptoPortfolio(walletEntry.userWallet.walletId)
return walletEntry.availableToAddAccounts[mainId]?.takeIf { it.isAvailableToAdd }
?: walletEntry.availableToAddAccounts.values.firstOrNull { it.isAvailableToAdd }
}
private fun pickFallbackAccount(walletEntry: AvailableToAddWallet): AvailableToAddAccount? {
val mainId = AccountId.forMainCryptoPortfolio(walletEntry.userWallet.walletId)
return walletEntry.availableToAddAccounts[mainId]
?: walletEntry.availableToAddAccounts.values.firstOrNull()
}
private suspend fun pickAddableNetwork(
userWallet: UserWallet,
account: AvailableToAddAccount,
orderedNetworks: List<TokenMarketInfo.Network>,
tokenParams: TokenMarketParams,
): TokenMarketInfo.Network? {
val availableOrdered = orderedNetworks.filter { candidate ->
account.availableToAddNetworks.any { it.networkId == candidate.networkId }
}
if (availableOrdered.isEmpty()) return null
val derivationIndex = account.account.account.derivationIndex
val withDerivation = availableOrdered.filter { network ->
val currency = getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = tokenParams,
network = network,
accountIndex = derivationIndex,
) ?: return@filter false
networkHasDerivationUseCase(userWallet, currency.network).getOrElse { false }
}
return withDerivation.firstOrNull() ?: availableOrdered.first()
}
data class InitialSelection(
val userWallet: UserWallet,
val account: AvailableToAddAccount,
val network: TokenMarketInfo.Network,
)
}

View file

@ -4,6 +4,7 @@ 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.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.QuickActionsConverter.toQuickActions
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -11,22 +12,29 @@ 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.DesignFeatureToggles
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
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.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.*
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
import com.tangem.features.commonfeatures.api.addtoportfolio.*
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state.UserPortfolioStateController
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
@ -40,20 +48,25 @@ import javax.inject.Inject
private const val TOKEN_ACTIONS_DELAY = 500L
@ModelScoped
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class AddToPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
designFeatureToggles: DesignFeatureToggles,
override val dispatchers: CoroutineDispatcherProvider,
val portfolioSelectorController: PortfolioSelectorController,
private val callbackDelegate: AddToPortfolioCallbackDelegate,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
val portfolioSelectorController: PortfolioSelectorController,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val selectionResolver: AddToPortfolioInitialSelectionResolver,
userPortfolioStateControllerFactory: UserPortfolioStateController.Factory,
) : Model(),
ChooseNetworkComponent.Callbacks by callbackDelegate,
TokenActionsComponent.Callbacks by callbackDelegate,
AddTokenComponent.Callbacks by callbackDelegate {
AddTokenComponent.Callbacks by callbackDelegate,
UserPortfolioComponent.Callbacks by callbackDelegate {
private val params = paramsContainer.require<AddToPortfolioComponent.Params>()
val navigation = StackNavigation<AddToPortfolioRoutes>()
@ -68,11 +81,24 @@ internal class AddToPortfolioModel @Inject constructor(
val portfolioFetcher: PortfolioFetcher = addToPortfolioManager.portfolioFetcher
val eventBuilder: MutableSharedFlow<PortfolioAnalyticsEvent.EventBuilder> = replayMutableSharedFlow()
val userPortfolioStateController = userPortfolioStateControllerFactory.create(
modelScope = modelScope,
onTokenSelected = { result -> addToPortfolioManager.onAddedTokenClick(result) },
)
val featureData: Flow<AddToPortfolioManager.State> = combineFeatureData()
private val globalSelectedWallet: UserWallet?
get() = getSelectedWalletSyncUseCase().getOrNull()
.takeIf { it?.isMultiCurrency == true }
init {
navigation.subscribe { currentStack = it.transformer.invoke(currentStack) }
startAddToPortfolioFlow()
if (designFeatureToggles.isRedesignEnabled) {
startRedesignAddToPortfolioFlow()
} else {
startLegacyAddToPortfolioFlow()
}
}
private fun <T> replayMutableSharedFlow() = MutableSharedFlow<T>(
@ -81,7 +107,7 @@ internal class AddToPortfolioModel @Inject constructor(
)
@Suppress("LongMethod")
private fun startAddToPortfolioFlow() {
private fun startLegacyAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishSuccessFlow(result: AddToPortfolioManager.Result) {
addToPortfolioManager.onSuccessAdded(result)
@ -208,6 +234,130 @@ internal class AddToPortfolioModel @Inject constructor(
.launchIn(modelScope)
}
@Suppress("LongMethod")
private fun startRedesignAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishSuccessFlow(result: AddToPortfolioManager.Result) {
addToPortfolioManager.onSuccessAdded(result)
channel.close()
}
fun finishDismissFlow() {
addToPortfolioManager.onDismiss()
channel.close()
}
val tokenMarketParams = addToPortfolioManager.paramsFlow.first().token
val eb = PortfolioAnalyticsEvent.EventBuilder(
tokenSymbol = tokenMarketParams.symbol,
source = addToPortfolioManager.analyticsParams.source,
)
eventBuilder.tryEmit(eb)
val launchMode = addToPortfolioManager.settings.launchMode
val initialData = featureData
.filterIsInstance<AddToPortfolioManager.State.Ready>()
.map { it.availableToAddData }
.first()
if (launchMode is AddToPortfolioManager.LaunchMode.ViaUserPortfolio &&
initialData.hasAnyAddedCurrency(launchMode.rawCurrencyId)
) {
// suspend, must prepare UM before navigate to UserPortfolio
userPortfolioStateController.updateAndWaitNotNullState(initialData, launchMode.rawCurrencyId)
navigation.replaceAll(AddToPortfolioRoutes.UserPortfolio)
callbackDelegate.onContinueFromUserPortfolio.receiveAsFlow().first()
}
val paramsSnapshot = addToPortfolioManager.paramsFlow.first()
val selection = selectionResolver.resolve(
availableToAddData = initialData,
orderedNetworks = paramsSnapshot.networks,
selectedWallet = globalSelectedWallet,
tokenParams = tokenMarketParams,
) ?: run {
finishDismissFlow()
return@channelFlow
}
val isAccountMode = portfolioSelectorController.isAccountModeSync()
portfolioSelectorController.selectAccount(selection.account.account.accountId)
val firstSelectedPortfolio = SelectedPortfolio(
isAccountMode = isAccountMode,
userWallet = selection.userWallet,
account = selection.account,
isAvailableMorePortfolio = !initialData.isSinglePortfolio,
)
val firstSelectedNetwork = selection.toSelectedNetwork() ?: run {
finishDismissFlow()
return@channelFlow
}
selectedPortfolio.emit(firstSelectedPortfolio)
selectedNetwork.emit(firstSelectedNetwork)
analyticsEventHandler.send(event = eventBuilder.first().popupToConfirm())
navigation.replaceAll(AddToPortfolioRoutes.AddToken)
var middleNavigationJob: Job? = null
callbackDelegate.onChangeNetworkClick.receiveAsFlow()
.onEach {
middleNavigationJob?.cancel()
middleNavigationJob = changeNetworkNavigationFlow()
.launchIn(this)
val route = routeToNetworkSelector(selectedPortfolio.first())
navigation.pushNew(route)
}
.launchIn(this)
callbackDelegate.onChangePortfolioClick.receiveAsFlow()
.onEach {
middleNavigationJob?.cancel()
middleNavigationJob = changePortfolioNavigationNewFlow(
data = initialData,
orderedNetworks = paramsSnapshot.networks,
tokenParams = tokenMarketParams,
).launchIn(this)
logAccountSelector(isAccountMode)
navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector)
}
.launchIn(this)
val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first()
middleNavigationJob?.cancel()
val selectedPortfolioSnapshot = selectedPortfolio.first()
val result = AddToPortfolioManager.Result(
wallet = selectedPortfolioSnapshot.userWallet,
account = selectedPortfolioSnapshot.account.account,
addedCurrency = addedToken,
)
messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added)))
if (addToPortfolioManager.settings.shouldSkipTokenActionsScreen) {
finishSuccessFlow(result)
return@channelFlow
}
setupTokenActionsFlow(selectedPortfolioSnapshot, addedToken)
.onEach { cryptoCurrencyData ->
tokenActionsData.emit(cryptoCurrencyData)
navigation.replaceAll(AddToPortfolioRoutes.TokenActions)
}
.onEmpty { finishSuccessFlow(result) }
.launchIn(this)
callbackDelegate.onLaterClick.receiveAsFlow().first()
finishSuccessFlow(result)
}
.catch { throwable ->
TangemLogger.e("Error", throwable)
addToPortfolioManager.onDismiss()
}
.launchIn(modelScope)
}
private suspend fun logAccountSelector(isAccountMode: Boolean) {
if (isAccountMode) {
analyticsEventHandler.send(eventBuilder.first().popupToChooseAccount())
@ -249,6 +399,38 @@ internal class AddToPortfolioModel @Inject constructor(
).collect { emit(it) }
}
private fun changePortfolioNavigationNewFlow(
data: AvailableToAddData,
orderedNetworks: List<TokenMarketInfo.Network>,
tokenParams: TokenMarketParams,
): Flow<Unit> {
return setupPortfolioFlow(data)
// drop first selected portfolio or any selected before
.drop(1)
.map { newPortfolio ->
val currentNetwork = selectedNetwork.first().selectedNetwork
val availableToAddNetworks = newPortfolio.account.availableToAddNetworks
val isSelectedNetworkAvailableForNewPortfolio = availableToAddNetworks
.any { it.networkId == currentNetwork.networkId }
if (!isSelectedNetworkAvailableForNewPortfolio) {
val selection = selectionResolver.resolve(
availableToAddData = data,
orderedNetworks = orderedNetworks,
selectedWallet = globalSelectedWallet,
tokenParams = tokenParams,
accountToAdd = newPortfolio.account,
)
val newNetwork = selection?.toSelectedNetwork()
if (newNetwork != null) {
this.selectedNetwork.tryEmit(newNetwork)
}
}
selectedPortfolio.tryEmit(newPortfolio)
navigation.popToFirst()
}
}
private fun setupTokenActionsFlow(
selectedPortfolio: SelectedPortfolio,
addedToken: CryptoCurrencyStatus,
@ -350,19 +532,34 @@ internal class AddToPortfolioModel @Inject constructor(
-> Unit
}
}
private suspend fun AddToPortfolioInitialSelectionResolver.InitialSelection.toSelectedNetwork(): SelectedNetwork? {
val crypto = createCryptoCurrency(
userWallet = userWallet,
network = network,
account = account,
) ?: return null
return SelectedNetwork(
cryptoCurrency = crypto,
selectedNetwork = network,
isAvailableMoreNetwork = !account.isSingleNetwork,
)
}
}
@ModelScoped
internal class AddToPortfolioCallbackDelegate @Inject constructor() :
ChooseNetworkComponent.Callbacks,
TokenActionsComponent.Callbacks,
AddTokenComponent.Callbacks {
AddTokenComponent.Callbacks,
UserPortfolioComponent.Callbacks {
val onNetworkSelected = Channel<TokenMarketInfo.Network>()
val onLaterClick = Channel<Unit>()
val onChangeNetworkClick = Channel<Unit>()
val onChangePortfolioClick = Channel<Unit>()
val onTokenAdded = Channel<CryptoCurrencyStatus>()
val onContinueFromUserPortfolio = Channel<Unit>()
override fun onNetworkSelected(network: TokenMarketInfo.Network) {
onNetworkSelected.trySend(network)
@ -383,4 +580,19 @@ internal class AddToPortfolioCallbackDelegate @Inject constructor() :
override fun onTokenAdded(status: CryptoCurrencyStatus) {
onTokenAdded.trySend(status)
}
override fun onContinueFromUserPortfolio() {
onContinueFromUserPortfolio.trySend(Unit)
}
}
private fun AvailableToAddData.hasAnyAddedCurrency(rawCurrencyId: CryptoCurrency.RawID): Boolean {
return availableToAddWallets.values.any { wallet ->
wallet.accounts.filterCryptoPortfolio().any { accountStatus ->
accountStatus.tokenList.flattenCurrencies().any { status ->
val id = status.currency.id.rawCurrencyId ?: return@any false
getTokenIdIfL2Network(id.value) == rawCurrencyId.value
}
}
}
}

View file

@ -23,6 +23,9 @@ internal sealed interface AddToPortfolioRoutes : Route {
@Serializable
data object AddToken : AddToPortfolioRoutes
@Serializable
data object UserPortfolio : AddToPortfolioRoutes
@Serializable
data object TokenActions : AddToPortfolioRoutes
}

View file

@ -26,6 +26,7 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor(
override val onDismiss: Channel<Unit> = Channel()
override val onSuccessAdded: Channel<AddToPortfolioManager.Result> = Channel()
override val onAddedTokenClick: Channel<AddToPortfolioManager.Result> = Channel()
override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true),
@ -63,6 +64,10 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor(
onSuccessAdded.trySend(result)
}
override fun onAddedTokenClick(result: AddToPortfolioManager.Result) {
onAddedTokenClick.trySend(result)
}
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
updateInternal(networks = networks)
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.markets.tokenselector.StickyFooter
import com.tangem.common.ui.markets.tokenselector.TokenSelectorEmbeddedContent
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultUserPortfolioComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: UserPortfolioComponent.Params,
) : UserPortfolioComponent, AppComponentContext by context {
private val model: UserPortfolioModel = getOrCreateModel(params)
private val onContinueClick: () -> Unit = params.callbacks::onContinueFromUserPortfolio
@Composable
override fun Content(modifier: Modifier) {
val stateFlow = model.state.collectAsStateWithLifecycle()
val state = stateFlow.value ?: return
TokenSelectorEmbeddedContent(
content = state.content,
modifier = modifier,
stickyFooter = StickyFooter(
buttonText = resourceReference(R.string.common_add),
isEnabled = state.isAddEnabled,
onClick = onContinueClick,
),
)
}
@AssistedFactory
interface Factory : UserPortfolioComponent.Factory {
override fun create(
context: AppComponentContext,
params: UserPortfolioComponent.Params,
): DefaultUserPortfolioComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import kotlinx.coroutines.flow.StateFlow
internal interface UserPortfolioComponent : ComposableContentComponent {
data class Params(
val uiState: StateFlow<UserPortfolioUM?>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onContinueFromUserPortfolio()
}
interface Factory : ComponentFactory<Params, UserPortfolioComponent>
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class UserPortfolioModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<UserPortfolioComponent.Params>()
val state: StateFlow<UserPortfolioUM?> = params.uiState
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM
@Immutable
internal data class UserPortfolioUM(
val content: TokenSelectorContentUM,
val isAddEnabled: Boolean,
)

View file

@ -0,0 +1,57 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer.UserPortfolioSectionsTransformer
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
internal class UserPortfolioStateController @AssistedInject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val onTokenSelected: (AddToPortfolioManager.Result) -> Unit,
) {
private val requiredDataFlow = MutableSharedFlow<Pair<AvailableToAddData, CryptoCurrency.RawID>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val uiState: StateFlow<UserPortfolioUM?> = combine(
flow = requiredDataFlow.distinctUntilChanged(),
flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { (allAvailableData, rawCurrencyId), appCurrency, isBalanceHidden ->
UserPortfolioSectionsTransformer(
availableData = allAvailableData,
rawCurrencyId = rawCurrencyId,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenSelected = onTokenSelected,
).transform()
}
.distinctUntilChanged()
.stateIn(modelScope, SharingStarted.Lazily, null)
suspend fun updateAndWaitNotNullState(allAvailableData: AvailableToAddData, rawCurrencyId: CryptoCurrency.RawID) {
requiredDataFlow.tryEmit(allAvailableData to rawCurrencyId)
uiState.filterNotNull().firstOrNull()
}
@AssistedFactory
interface Factory {
fun create(
modelScope: CoroutineScope,
onTokenSelected: (AddToPortfolioManager.Result) -> Unit,
): UserPortfolioStateController
}
}

View file

@ -0,0 +1,187 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.markets.tokenselector.*
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.domain.appcurrency.model.AppCurrency
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.wallet.UserWallet
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
internal class UserPortfolioSectionsTransformer(
private val availableData: AvailableToAddData,
private val rawCurrencyId: CryptoCurrency.RawID,
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenSelected: (AddToPortfolioManager.Result) -> Unit,
) {
private val iconConverter = CryptoCurrencyToIconStateConverter()
fun transform(): UserPortfolioUM {
return UserPortfolioUM(
content = TokenSelectorContentUM(
sections = buildSections(entries = generateEntries(availableData)).toImmutableList(),
),
isAddEnabled = availableData.isAvailableToAdd,
)
}
private fun generateEntries(data: AvailableToAddData): List<PortfolioEntry> {
return data.availableToAddWallets.values.flatMap { wallet ->
wallet.accounts.filterCryptoPortfolio().flatMap { accountStatus ->
accountStatus.tokenList.flattenCurrencies()
.filter { status -> status.currency.matchesRawId(rawCurrencyId) }
.map { status ->
PortfolioEntry(
wallet = wallet.userWallet,
account = accountStatus,
currencyStatus = status,
)
}
}
}
}
private fun buildSections(entries: List<PortfolioEntry>): List<TokenSelectorSectionUM> {
val sections = mutableListOf<TokenSelectorSectionUM>()
val byWallet = entries.groupBy { it.wallet.walletId }
val shouldShowWalletHeaders = byWallet.size > 1
for ((_, walletEntries) in byWallet) {
if (shouldShowWalletHeaders) {
sections.add(
TokenSelectorSectionUM.WalletHeader(walletName = walletEntries.first().wallet.name),
)
}
val byAccount = walletEntries.groupBy { it.account.account.accountId }
val shouldShowAccountHeaders = byAccount.size > 1
for ((_, accountEntries) in byAccount) {
val singles = accountEntries.map(::entryToSingle).toImmutableList()
val accountHeader = if (shouldShowAccountHeaders) {
val first = accountEntries.first()
AccountHeaderData(
accountName = first
.account
.account
.accountName
.toUM()
.value,
cryptoPortfolioIcon = first.account.account.icon,
)
} else {
null
}
sections.add(
TokenSelectorSectionUM.TokenGroup(accountHeader = accountHeader, items = singles),
)
}
}
return sections
}
private fun entryToSingle(entry: PortfolioEntry): UserAssetItemUM.Single {
val currency = entry.currencyStatus.currency
val value = entry.currencyStatus.value
return UserAssetItemUM.Single(
id = "${entry.wallet.walletId.stringValue}_${entry.account.account.accountId.value}_${currency.id.value}",
icon = TangemIconUM.Currency(currencyIconState = iconConverter.convert(entry.currencyStatus)),
tokenName = currency.name,
tokenSymbol = currency.symbol,
fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) },
priceChangeState = when (value) {
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.NoAmount,
-> PriceChangeState.Unknown
else -> PriceChangeState.Content(
type = PriceChangeType.fromBigDecimal(value.priceChange.orZero()),
valueInPercent = value.priceChange.format { percent() },
)
},
balanceState = convertBalanceState(value, currency.symbol, currency.decimals),
isBalanceHidden = isBalanceHidden,
onClick = {
onTokenSelected(
AddToPortfolioManager.Result(
wallet = entry.wallet,
account = entry.account,
addedCurrency = entry.currencyStatus,
),
)
},
networkName = currency.network.name,
)
}
private data class PortfolioEntry(
val wallet: UserWallet,
val account: AccountStatus.CryptoPortfolio,
val currencyStatus: CryptoCurrencyStatus,
)
private fun convertBalanceState(
value: CryptoCurrencyStatus.Value,
symbol: String,
decimals: Int,
): BalanceDisplayState {
return when {
value is CryptoCurrencyStatus.Loading && value.amount != null ->
BalanceDisplayState.Flickering(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading
value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable
value.isError && value.amount != null ->
BalanceDisplayState.Stale(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
value.isError -> BalanceDisplayState.Unreachable
else -> BalanceDisplayState.Loaded(
cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)),
fiatBalance = stringReference(
value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) }
?: StringsSigns.DASH_SIGN,
),
)
}
}
private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String {
return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN
}
private fun CryptoCurrency.matchesRawId(target: CryptoCurrency.RawID): Boolean {
val rawId = id.rawCurrencyId ?: return false
return getTokenIdIfL2Network(rawId.value) == target.value
}
}

View file

@ -0,0 +1,591 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AddToPortfolioInitialSelectionResolverTest {
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency = mockk()
private val networkHasDerivationUseCase: NetworkHasDerivationUseCase = mockk()
private val tokenParams: TokenMarketParams = mockk()
private lateinit var resolver: AddToPortfolioInitialSelectionResolver
@BeforeEach
fun setup() {
clearMocks(getTokenMarketCryptoCurrency, networkHasDerivationUseCase)
coEvery { getTokenMarketCryptoCurrency(any(), any(), any(), any()) } returns null
every { networkHasDerivationUseCase(any(), any()) } returns false.right()
resolver = AddToPortfolioInitialSelectionResolver(
getTokenMarketCryptoCurrency = getTokenMarketCryptoCurrency,
networkHasDerivationUseCase = networkHasDerivationUseCase,
)
}
@Test
fun `GIVEN no wallets in data WHEN resolve THEN return null`() = runTest {
val data = availableData(wallets = emptyMap())
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = null,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNull()
}
@Test
fun `GIVEN accountToAdd is provided WHEN resolve THEN use it instead of looking up account`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val mainAccount = availableAccount()
val explicitAccount = availableAccount(availableToAddNetworks = setOf(BITCOIN))
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to mainAccount),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(BITCOIN),
selectedWallet = userWallet,
tokenParams = tokenParams,
accountToAdd = explicitAccount,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount)
Truth.assertThat(result.network).isEqualTo(BITCOIN)
}
@Test
fun `GIVEN accountToAdd is not available to add WHEN resolve THEN still use it`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val explicitAccount = availableAccount(isAvailableToAdd = false, availableToAddNetworks = setOf(ETHEREUM))
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = userWallet,
tokenParams = tokenParams,
accountToAdd = explicitAccount,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount)
}
@Test
fun `GIVEN accountToAdd with no matching ordered networks WHEN resolve THEN fall back to first ordered network`() =
runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM))
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(BITCOIN),
selectedWallet = userWallet,
tokenParams = tokenParams,
accountToAdd = explicitAccount,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount)
Truth.assertThat(result.network).isEqualTo(BITCOIN)
}
@Test
fun `GIVEN selected wallet is in data WHEN resolve THEN pick its entry`() = runTest {
val selectedWalletId = UserWalletId(WALLET_ID_A)
val otherWalletId = UserWalletId(WALLET_ID_B)
val selectedUserWallet = userWallet(selectedWalletId)
val otherUserWallet = userWallet(otherWalletId)
val selectedAccount = availableAccount()
val otherAccount = availableAccount()
val data = availableData(
wallets = linkedMapOf(
otherWalletId to walletEntry(otherUserWallet, mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherAccount)),
selectedWalletId to walletEntry(selectedUserWallet, mapOf(AccountId.forMainCryptoPortfolio(selectedWalletId) to selectedAccount)),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = selectedUserWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.userWallet).isSameInstanceAs(selectedUserWallet)
Truth.assertThat(result.account).isSameInstanceAs(selectedAccount)
}
@Test
fun `GIVEN selected wallet is not in data WHEN resolve THEN fall back to first wallet`() = runTest {
val firstWalletId = UserWalletId(WALLET_ID_A)
val firstUserWallet = userWallet(firstWalletId)
val firstAccount = availableAccount()
val data = availableData(
wallets = mapOf(
firstWalletId to walletEntry(firstUserWallet, mapOf(AccountId.forMainCryptoPortfolio(firstWalletId) to firstAccount)),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = userWallet(UserWalletId(WALLET_ID_B)),
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.userWallet).isSameInstanceAs(firstUserWallet)
}
@Test
fun `GIVEN selected wallet is null WHEN resolve THEN fall back to first wallet`() = runTest {
val firstWalletId = UserWalletId(WALLET_ID_A)
val firstUserWallet = userWallet(firstWalletId)
val firstAccount = availableAccount()
val data = availableData(
wallets = mapOf(
firstWalletId to walletEntry(firstUserWallet, mapOf(AccountId.forMainCryptoPortfolio(firstWalletId) to firstAccount)),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = null,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.userWallet).isSameInstanceAs(firstUserWallet)
}
@Test
fun `GIVEN main account is available WHEN resolve THEN pick main account`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val mainAccount = availableAccount()
val otherAccount = availableAccount()
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = linkedMapOf(
AccountId.forPaymentAccount(walletId) to otherAccount,
AccountId.forMainCryptoPortfolio(walletId) to mainAccount,
),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = userWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.account).isSameInstanceAs(mainAccount)
}
@Test
fun `GIVEN main account is not available WHEN resolve THEN pick first available account`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val mainAccount = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet())
val secondAvailable = availableAccount()
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = linkedMapOf(
AccountId.forMainCryptoPortfolio(walletId) to mainAccount,
AccountId.forPaymentAccount(walletId) to secondAvailable,
),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = userWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.account).isSameInstanceAs(secondAvailable)
}
@Test
fun `GIVEN no available accounts WHEN resolve THEN fall back to that account with first ordered network`() =
runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val unavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet())
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to unavailable),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = userWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.userWallet).isSameInstanceAs(userWallet)
Truth.assertThat(result.account).isSameInstanceAs(unavailable)
Truth.assertThat(result.network).isEqualTo(ETHEREUM)
}
@Test
fun `GIVEN ordered networks do not match account networks WHEN resolve THEN fall back to first ordered network`() =
runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM))
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(BITCOIN),
selectedWallet = userWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.userWallet).isSameInstanceAs(userWallet)
Truth.assertThat(result.account).isSameInstanceAs(account)
Truth.assertThat(result.network).isEqualTo(BITCOIN)
}
@Test
fun `GIVEN preferred wallet has no available accounts but another wallet does WHEN resolve THEN pick the other`() =
runTest {
val preferredWalletId = UserWalletId(WALLET_ID_A)
val otherWalletId = UserWalletId(WALLET_ID_B)
val preferredUserWallet = userWallet(preferredWalletId)
val otherUserWallet = userWallet(otherWalletId)
val preferredUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet())
val otherAvailable = availableAccount(availableToAddNetworks = setOf(ETHEREUM))
val data = availableData(
wallets = linkedMapOf(
preferredWalletId to walletEntry(
userWallet = preferredUserWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(preferredWalletId) to preferredUnavailable),
),
otherWalletId to walletEntry(
userWallet = otherUserWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherAvailable),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = preferredUserWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.userWallet).isSameInstanceAs(otherUserWallet)
Truth.assertThat(result.account).isSameInstanceAs(otherAvailable)
Truth.assertThat(result.network).isEqualTo(ETHEREUM)
}
@Test
fun `GIVEN no wallet has viable combo WHEN resolve THEN fall back to preferred wallet with first ordered network`() =
runTest {
val preferredWalletId = UserWalletId(WALLET_ID_A)
val otherWalletId = UserWalletId(WALLET_ID_B)
val preferredUserWallet = userWallet(preferredWalletId)
val otherUserWallet = userWallet(otherWalletId)
val preferredUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet())
val otherUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet())
val data = availableData(
wallets = linkedMapOf(
otherWalletId to walletEntry(
userWallet = otherUserWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherUnavailable),
),
preferredWalletId to walletEntry(
userWallet = preferredUserWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(preferredWalletId) to preferredUnavailable),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM),
selectedWallet = preferredUserWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result).isNotNull()
Truth.assertThat(result!!.userWallet).isSameInstanceAs(preferredUserWallet)
Truth.assertThat(result.account).isSameInstanceAs(preferredUnavailable)
Truth.assertThat(result.network).isEqualTo(ETHEREUM)
}
@Test
fun `GIVEN first ordered network has derivation WHEN resolve THEN pick first`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN))
val ethereumCurrency = cryptoCurrency()
val bitcoinCurrency = cryptoCurrency()
coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency
coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency
every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns true.right()
every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right()
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM, BITCOIN),
selectedWallet = userWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result?.network).isEqualTo(ETHEREUM)
}
@Test
fun `GIVEN only second ordered network has derivation WHEN resolve THEN pick second`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN))
val ethereumCurrency = cryptoCurrency()
val bitcoinCurrency = cryptoCurrency()
coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency
coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency
every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns false.right()
every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right()
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM, BITCOIN),
selectedWallet = userWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result?.network).isEqualTo(BITCOIN)
}
@Test
fun `GIVEN no network has derivation WHEN resolve THEN fall back to first ordered available network`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN))
val ethereumCurrency = cryptoCurrency()
val bitcoinCurrency = cryptoCurrency()
coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency
coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency
every { networkHasDerivationUseCase(any(), any()) } returns Throwable().left()
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(BITCOIN, ETHEREUM),
selectedWallet = userWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result?.network).isEqualTo(BITCOIN)
}
@Test
fun `GIVEN get token market crypto currency returns null WHEN resolve THEN fall back to first ordered available network`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN))
coEvery { getTokenMarketCryptoCurrency(any(), any(), any(), any()) } returns null
val data = availableData(
wallets = mapOf(
walletId to walletEntry(
userWallet = userWallet,
accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(BITCOIN, ETHEREUM),
selectedWallet = userWallet,
tokenParams = tokenParams,
)
Truth.assertThat(result?.network).isEqualTo(BITCOIN)
}
// region Helpers
private fun availableData(wallets: Map<UserWalletId, AvailableToAddWallet>): AvailableToAddData = mockk {
every { isAvailableToAdd } returns true
every { availableToAddWallets } returns wallets
}
private fun walletEntry(
userWallet: UserWallet,
accounts: Map<AccountId, AvailableToAddAccount>,
): AvailableToAddWallet = mockk {
every { this@mockk.userWallet } returns userWallet
every { availableToAddAccounts } returns accounts
}
private fun availableAccount(
isAvailableToAdd: Boolean = true,
availableToAddNetworks: Set<TokenMarketInfo.Network> = setOf(ETHEREUM),
derivationIndex: DerivationIndex = DerivationIndex.Main,
): AvailableToAddAccount = mockk {
every { this@mockk.isAvailableToAdd } returns isAvailableToAdd
every { this@mockk.availableToAddNetworks } returns availableToAddNetworks
every { account.account.derivationIndex } returns derivationIndex
}
private fun userWallet(walletId: UserWalletId): UserWallet = mockk {
every { this@mockk.walletId } returns walletId
}
private fun cryptoCurrency(): CryptoCurrency = mockk {
every { network } returns mockk<Network>()
}
// endregion
private companion object {
const val WALLET_ID_A = "011f"
const val WALLET_ID_B = "022e"
val ETHEREUM = TokenMarketInfo.Network(
networkId = "ethereum",
isExchangeable = true,
contractAddress = null,
decimalCount = 18,
)
val BITCOIN = TokenMarketInfo.Network(
networkId = "bitcoin",
isExchangeable = true,
contractAddress = null,
decimalCount = 8,
)
}
}