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

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.search.components
package com.tangem.common.ui.markets.tokenselector
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
@ -17,15 +17,10 @@ import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.utils.StringsSigns
@Composable
internal fun BalanceColumn(
balanceState: BalanceDisplayState,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
fun BalanceColumn(balanceState: BalanceDisplayState, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
if (isBalanceHidden) {
HiddenBalance(modifier)
return

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.search.components
package com.tangem.common.ui.markets.tokenselector
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -10,18 +10,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.pluralStringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.components.LayeringIcons
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
@Composable
internal fun GroupedUserAssetItem(item: UserAssetItemUM.Grouped, modifier: Modifier = Modifier) {
fun GroupedUserAssetItem(item: UserAssetItemUM.Grouped, modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = TangemTheme.colors2.surface.level3,

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.components
package com.tangem.common.ui.markets.tokenselector
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.search.components
package com.tangem.common.ui.markets.tokenselector
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
@ -17,8 +17,6 @@ import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
@Composable
fun SingleUserAssetItem(shouldUsePriceBlock: Boolean, item: UserAssetItemUM.Single, modifier: Modifier = Modifier) {

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.search.components
package com.tangem.common.ui.markets.tokenselector
import android.content.res.Configuration
import androidx.compose.animation.core.EaseOut
@ -25,59 +25,114 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.feed.ui.search.preview.TokenSelectorContentPreviewProvider
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.rememberHazeState
@Composable
internal fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig) {
fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig, stickyFooter: StickyFooter? = null) {
TangemBottomSheet<TokenSelectorContentUM>(
config = config,
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors2.surface.level2,
content = { content ->
TokenSelectorContent(content, config.onDismissRequest)
TokenSelectorContent(
content = content,
onDismiss = config.onDismissRequest,
stickyFooter = stickyFooter,
embedded = false,
)
},
)
}
data class StickyFooter(val buttonText: TextReference, val isEnabled: Boolean = true, val onClick: () -> Unit)
@Composable
private fun TokenSelectorContent(content: TokenSelectorContentUM, onDismiss: () -> Unit) {
fun TokenSelectorEmbeddedContent(
content: TokenSelectorContentUM,
stickyFooter: StickyFooter?,
modifier: Modifier = Modifier,
) {
TokenSelectorContent(
content = content,
onDismiss = {},
stickyFooter = stickyFooter,
embedded = true,
modifier = modifier,
)
}
@Composable
private fun TokenSelectorContent(
content: TokenSelectorContentUM,
onDismiss: () -> Unit,
stickyFooter: StickyFooter?,
embedded: Boolean,
modifier: Modifier = Modifier,
) {
val hazeState = rememberHazeState()
var topBarHeight by remember { mutableStateOf(0.dp) }
val topContentPadding = if (embedded) {
TangemTheme.dimens2.x4
} else {
topBarHeight
}
val footerHeight = TangemTheme.dimens2.x14 + TangemTheme.dimens2.x4
val listBottomPadding = TangemTheme.dimens2.x10 + if (stickyFooter != null) footerHeight else 0.dp
Box(modifier = Modifier.fillMaxWidth()) {
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.hazeSourceTangem(state = hazeState, 1f),
contentPadding = PaddingValues(
start = TangemTheme.dimens2.x4,
end = TangemTheme.dimens2.x4,
top = topBarHeight,
bottom = TangemTheme.dimens2.x10,
top = topContentPadding,
bottom = listBottomPadding,
),
) {
tokenSelectorSectionItems(content.sections)
}
TokenSelectorSheetTopBar(
modifier = Modifier.align(Alignment.TopEnd),
onDismiss = onDismiss,
hazeState = hazeState,
onChangeHeight = { topBarHeight = it },
)
if (!embedded) {
TokenSelectorSheetTopBar(
modifier = Modifier.align(Alignment.TopEnd),
onDismiss = onDismiss,
hazeState = hazeState,
onChangeHeight = { topBarHeight = it },
)
}
Fade(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
.align(Alignment.BottomCenter)
.padding(bottom = if (stickyFooter != null) footerHeight else 0.dp),
height = TangemTheme.dimens2.x10,
)
if (stickyFooter != null) {
TangemButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2)
.fillMaxWidth(),
buttonUM = TangemButtonUM(
type = TangemButtonType.Primary,
text = stickyFooter.buttonText,
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X15,
isEnabled = stickyFooter.isEnabled,
onClick = stickyFooter.onClick,
),
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.search.preview
package com.tangem.common.ui.markets.tokenselector
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.R
@ -8,15 +8,10 @@ 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.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.feed.ui.search.state.AccountHeaderData
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import kotlinx.collections.immutable.persistentListOf
@Suppress("StringLiteralDuplication")
internal class TokenSelectorContentPreviewProvider :
class TokenSelectorContentPreviewProvider :
CollectionPreviewParameterProvider<TokenSelectorContentUM>(
listOf(
tokenSelectorPreviewSimple(),

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.search.state
package com.tangem.common.ui.markets.tokenselector
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
@ -7,18 +7,18 @@ import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class TokenSelectorContentUM(
data class TokenSelectorContentUM(
val sections: ImmutableList<TokenSelectorSectionUM>,
) : TangemBottomSheetConfigContent
@Immutable
internal data class AccountHeaderData(
data class AccountHeaderData(
val accountName: TextReference,
val cryptoPortfolioIcon: CryptoPortfolioIcon,
)
@Immutable
internal sealed interface TokenSelectorSectionUM {
sealed interface TokenSelectorSectionUM {
data class WalletHeader(val walletName: String) : TokenSelectorSectionUM

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.search.components
package com.tangem.common.ui.markets.tokenselector
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@ -21,11 +21,9 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.search.state.AccountHeaderData
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import kotlinx.collections.immutable.ImmutableList
internal fun LazyListScope.tokenSelectorSectionItems(sections: ImmutableList<TokenSelectorSectionUM>) {
fun LazyListScope.tokenSelectorSectionItems(sections: ImmutableList<TokenSelectorSectionUM>) {
sections.forEachIndexed { index, section ->
when (section) {
is TokenSelectorSectionUM.WalletHeader -> {

View file

@ -0,0 +1,61 @@
package com.tangem.common.ui.markets.tokenselector
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
sealed interface BalanceDisplayState {
data class Loaded(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data class Flickering(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data class Stale(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data object Loading : BalanceDisplayState
data object Unreachable : BalanceDisplayState
}
@Immutable
sealed interface UserAssetItemUM {
val id: String
val icon: TangemIconUM
val tokenName: String
val tokenSymbol: String
val onClick: () -> Unit
data class Single(
override val id: String,
override val icon: TangemIconUM,
override val tokenName: String,
override val tokenSymbol: String,
val fiatRate: String?,
val priceChangeState: PriceChangeState,
val balanceState: BalanceDisplayState,
val isBalanceHidden: Boolean,
val networkName: String,
override val onClick: () -> Unit,
) : UserAssetItemUM
data class Grouped(
override val id: String,
override val icon: TangemIconUM,
override val tokenName: String,
override val tokenSymbol: String,
val tokensCount: Int,
val balanceState: BalanceDisplayState,
val isBalanceHidden: Boolean,
override val onClick: () -> Unit,
) : UserAssetItemUM
}

View file

@ -1,4 +1,4 @@
package com.tangem.domain.search.model
package com.tangem.domain.models.portfolio
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
@ -6,7 +6,7 @@ import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
data class UserAssetSearchEntry(
data class UserAssetEntry(
val userWalletId: UserWalletId,
val userWalletName: String,
val accountId: AccountId,

View file

@ -1,13 +1,15 @@
package com.tangem.domain.search.model
import com.tangem.domain.models.portfolio.UserAssetEntry
sealed interface UserAssetSearchItem {
data class Single(val entry: UserAssetSearchEntry) : UserAssetSearchItem
data class Single(val entry: UserAssetEntry) : UserAssetSearchItem
data class Grouped(
val tokenName: String,
val tokenSymbol: String,
val tokenIconUrl: String?,
val entries: List<UserAssetSearchEntry>,
val entries: List<UserAssetEntry>,
) : UserAssetSearchItem
}

View file

@ -8,7 +8,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.search.model.SearchResult
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.models.portfolio.UserAssetEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.domain.search.repository.SearchRepository
import kotlinx.coroutines.flow.Flow
@ -97,7 +97,7 @@ class GetSearchResultsUseCase(
return totalAccounts > 1
}
private fun groupAndSort(entries: List<UserAssetSearchEntry>, shouldGroup: Boolean): List<UserAssetSearchItem> {
private fun groupAndSort(entries: List<UserAssetEntry>, shouldGroup: Boolean): List<UserAssetSearchItem> {
if (!shouldGroup) {
return entries
.map { UserAssetSearchItem.Single(it) }
@ -129,7 +129,7 @@ class GetSearchResultsUseCase(
statusList: AccountStatusList,
wallets: Map<UserWalletId, UserWallet>,
lowerQuery: String,
): List<UserAssetSearchEntry> {
): List<UserAssetEntry> {
val wallet = wallets[statusList.userWalletId] ?: return emptyList()
return statusList.accountStatuses
.filterCryptoPortfolio()
@ -141,7 +141,7 @@ class GetSearchResultsUseCase(
name.contains(lowerQuery) || symbol.contains(lowerQuery)
}
.map { currencyStatus ->
UserAssetSearchEntry(
UserAssetEntry(
userWalletId = statusList.userWalletId,
userWalletName = wallet.name,
accountId = accountStatus.accountId,

View file

@ -5,9 +5,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
interface AddToPortfolioComponent : ComposableBottomSheetComponent {
data class Params(
val addToPortfolioManager: AddToPortfolioManager,
)
data class Params(val addToPortfolioManager: AddToPortfolioManager)
interface Factory : ComponentFactory<Params, AddToPortfolioComponent>
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.commonfeatures.api.addtoportfolio
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
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.commonfeatures.api.portfolioselector.PortfolioFetcher
@ -18,6 +19,7 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal {
val onDismiss: Channel<Unit>
val onSuccessAdded: Channel<Result>
val onAddedTokenClick: Channel<Result>
val portfolioFetcher: PortfolioFetcher
val state: StateFlow<State>
@ -27,36 +29,34 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal {
sealed interface State {
data object Loading : State
data class Ready(
val availableToAddData: AvailableToAddData,
) : State {
data class Ready(val availableToAddData: AvailableToAddData) : State {
val isAvailableToAdd: Boolean get() = availableToAddData.isAvailableToAdd
val isSinglePortfolio: Boolean get() = availableToAddData.isSinglePortfolio
}
}
@Serializable
data class AnalyticsParams(
val source: String?,
)
data class AnalyticsParams(val source: String?)
interface Factory {
fun create(scope: CoroutineScope, settings: Settings, analyticsParams: AnalyticsParams): AddToPortfolioManager
}
sealed interface LaunchMode {
data object DirectAdd : LaunchMode
data class ViaUserPortfolio(val rawCurrencyId: CryptoCurrency.RawID) : LaunchMode
}
/**
* Immutable settings
*/
data class Settings(
val shouldSkipTokenActionsScreen: Boolean = false,
val launchMode: LaunchMode = LaunchMode.DirectAdd,
) {
companion object {
val DefaultMarket = Settings(
shouldSkipTokenActionsScreen = false,
)
val ChooseToken = Settings(
shouldSkipTokenActionsScreen = true,
)
val DefaultMarket = Settings(shouldSkipTokenActionsScreen = false)
val ChooseToken = Settings(shouldSkipTokenActionsScreen = true)
}
}
@ -64,10 +64,7 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal {
* Mutable parameters
* Updates may trigger reload [State]
*/
data class Params(
val networks: List<TokenMarketInfo.Network>,
val token: TokenMarketParams,
)
data class Params(val networks: List<TokenMarketInfo.Network>, val token: TokenMarketParams)
data class Result(
val wallet: UserWallet,
@ -88,4 +85,5 @@ interface AddToPortfolioManagerInternal {
fun onDismiss()
fun onSuccessAdded(result: AddToPortfolioManager.Result)
fun onAddedTokenClick(result: AddToPortfolioManager.Result)
}

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.commonfeatures.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.commonFeatures.api)
@ -56,6 +60,9 @@ dependencies {
implementation(projects.common.uiMarkets)
implementation(projects.common.routing)
/** Libs */
implementation(projects.libs.blockchainSdk)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
implementation(deps.lifecycle.runtime.ktx)
@ -79,4 +86,9 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
}

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

View file

@ -11,6 +11,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
@ -29,6 +30,7 @@ internal class FeedEntryChildFactory @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
private val portfolioBlockComponentFactory: PortfolioBlockComponent.Factory,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
@ -84,6 +86,7 @@ internal class FeedEntryChildFactory @Inject constructor(
portfolioComponentFactory = portfolioComponentFactory,
portfolioBlockComponentFactory = portfolioBlockComponentFactory,
designFeatureToggles = designFeatureToggles,
addToPortfolioComponentFactory = addToPortfolioComponentFactory,
)
}
is Child.TokenList -> {

View file

@ -0,0 +1,7 @@
package com.tangem.features.feed.components.market.details
import com.tangem.core.decompose.navigation.Route
import kotlinx.serialization.Serializable
@Serializable
internal data object AddToPortfolioSlotRoute : Route

View file

@ -16,16 +16,20 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.DesignFeatureToggles
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
@ -34,10 +38,13 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
@ -48,13 +55,15 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Suppress("LongParameterList")
internal class DefaultMarketsTokenDetailsComponent(
appComponentContext: AppComponentContext,
val params: Params,
analyticsEventHandler: AnalyticsEventHandler,
designFeatureToggles: DesignFeatureToggles,
portfolioComponentFactory: MarketsPortfolioComponent.Factory,
portfolioBlockComponentFactory: PortfolioBlockComponent.Factory,
val params: Params,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
// applying l2 compatibility
@ -83,12 +92,28 @@ internal class DefaultMarketsTokenDetailsComponent(
if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) {
portfolioBlockComponentFactory.create(
context = child("portfolio_block"),
params = PortfolioBlockComponent.Params(updatedParams.token),
params = PortfolioBlockComponent.Params(token = updatedParams.token),
parentRouter = object : PortfolioBlockParentClickIntents {
override fun openAddToPortfolioDirect() {
model.openAddToPortfolio()
}
override fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) {
model.openAddToPortfolioViaUserPortfolio(rawCurrencyId)
}
},
)
} else {
null
}
private val addToPortfolioSlot = childSlot(
source = model.addToPortfolioSheetNavigation,
serializer = AddToPortfolioSlotRoute.serializer(),
handleBackButton = false,
childFactory = ::addToPortfolioChild,
)
init {
componentScope.launch(dispatchers.default) {
model.networksState.collectLatest { state ->
@ -120,6 +145,17 @@ internal class DefaultMarketsTokenDetailsComponent(
}
}
private fun addToPortfolioChild(
@Suppress("UNUSED_PARAMETER") config: AddToPortfolioSlotRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent {
val manager = model.addToPortfolioManagerOrNull() ?: return ComposableBottomSheetComponent.EMPTY
return addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(addToPortfolioManager = manager),
)
}
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
@ -198,6 +234,7 @@ internal class DefaultMarketsTokenDetailsComponent(
}
}
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by addToPortfolioSlot.subscribeAsState()
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED
@ -219,6 +256,7 @@ internal class DefaultMarketsTokenDetailsComponent(
}
},
)
bottomSheet.child?.instance?.BottomSheet()
}
@Serializable

View file

@ -5,24 +5,13 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
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.sheet.TangemBottomSheet
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockModel
import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockRoute
import com.tangem.features.feed.components.market.details.portfolioblock.ui.PortfolioBlock
import com.tangem.features.feed.components.portfolio.PortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -32,26 +21,14 @@ import kotlinx.serialization.Serializable
internal class PortfolioBlockComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
@Assisted private val parentRouter: PortfolioBlockParentClickIntents?,
) : ComposableContentComponent, AppComponentContext by context {
private val portfolioComponentFactory: PortfolioComponent.Factory = object : PortfolioComponent.Factory {
override fun create(context: AppComponentContext, params: PortfolioComponent.Params): PortfolioComponent {
TODO("STUB. Will be implemented")
}
}
@Serializable
data class Params(
val token: TokenMarketParams,
)
private val model: PortfolioBlockModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = PortfolioBlockRoute.serializer(),
handleBackButton = true,
childFactory = ::createBottomSheetChild,
private val model: PortfolioBlockModel = getOrCreateModel(
PortfolioBlockModelParams(
token = params.token,
parentRouter = parentRouter,
),
)
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
@ -65,51 +42,23 @@ internal class PortfolioBlockComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
PortfolioBlock(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnusedParameter")
private fun createBottomSheetChild(
config: PortfolioBlockRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent {
val currencyId = model.cryptoCurrencyIdState.value ?: return ComposableBottomSheetComponent.EMPTY
val portfolioComponent = portfolioComponentFactory.create(
context = childByContext(componentContext),
params = PortfolioComponent.Params(id = currencyId),
)
return PortfolioBottomSheetWrapper(
portfolioComponent = portfolioComponent,
onDismiss = { model.bottomSheetNavigation.dismiss() },
)
}
private class PortfolioBottomSheetWrapper(
private val portfolioComponent: PortfolioComponent,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() = onDismiss()
@Composable
override fun BottomSheet() {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
),
) {
portfolioComponent.Content(modifier = Modifier)
}
}
}
@Serializable
data class Params(val token: TokenMarketParams)
@AssistedFactory
interface Factory {
fun create(context: AppComponentContext, params: Params): PortfolioBlockComponent
fun create(
context: AppComponentContext,
params: Params,
parentRouter: PortfolioBlockParentClickIntents?,
): PortfolioBlockComponent
}
data class PortfolioBlockModelParams(
val token: TokenMarketParams,
val parentRouter: PortfolioBlockParentClickIntents?,
)
}

View file

@ -0,0 +1,8 @@
package com.tangem.features.feed.components.market.details.portfolioblock
import com.tangem.domain.models.currency.CryptoCurrency
internal interface PortfolioBlockParentClickIntents {
fun openAddToPortfolioDirect()
fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID)
}

View file

@ -2,8 +2,6 @@ package com.tangem.features.feed.components.market.details.portfolioblock.model
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.SpanStyle
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.getTotalFiatAmount
import com.tangem.core.decompose.di.ModelScoped
@ -26,6 +24,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents
import com.tangem.features.feed.components.market.details.portfolioblock.ui.state.PortfolioBlockUM
import com.tangem.features.feed.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -50,13 +49,12 @@ internal class PortfolioBlockModel @Inject constructor(
val state: StateFlow<PortfolioBlockUM>
field = MutableStateFlow<PortfolioBlockUM>(PortfolioBlockUM.Loading)
val bottomSheetNavigation: SlotNavigation<PortfolioBlockRoute> = SlotNavigation()
val cryptoCurrencyIdState: StateFlow<CryptoCurrency.ID?>
field = MutableStateFlow(null)
private val params = paramsContainer.require<PortfolioBlockComponent.Params>()
private val params = paramsContainer.require<PortfolioBlockComponent.PortfolioBlockModelParams>()
private val currencyRawId: CryptoCurrency.RawID = params.token.id
private val parentRouter: PortfolioBlockParentClickIntents? = params.parentRouter
private val tokenIcon: CurrencyIconState = CurrencyIconState.CoinIcon(
url = params.token.imageUrl,
@ -114,15 +112,12 @@ internal class PortfolioBlockModel @Inject constructor(
}
}.distinctUntilChanged()
val settingsFlow = combine(
return combine(
portfolioDataFlow,
getSelectedAppCurrencyUseCase.invokeOrDefault(),
getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { appCurrency, isBalanceHidden ->
SettingsBox(appCurrency, isBalanceHidden)
}.distinctUntilChanged()
return combine(portfolioDataFlow, settingsFlow) { portfolios, settings ->
buildState(portfolios, settings)
) { portfolios, appCurrency, isBalanceHidden ->
buildState(portfolios, appCurrency, isBalanceHidden)
}.distinctUntilChanged()
}
@ -135,7 +130,11 @@ internal class PortfolioBlockModel @Inject constructor(
}
}
private fun buildState(portfolios: List<WalletPortfolio>, settings: SettingsBox): PortfolioBlockUM {
private fun buildState(
portfolios: List<WalletPortfolio>,
appCurrency: AppCurrency,
isBalanceHidden: Boolean,
): PortfolioBlockUM {
val allCurrencies = portfolios.flatMap { it.currencies }
val hasMultiCurrencyWallet = portfolios.any { it.userWallet.isMultiCurrency }
@ -143,36 +142,36 @@ internal class PortfolioBlockModel @Inject constructor(
return if (hasMultiCurrencyWallet) {
PortfolioBlockUM.AddToken(
tokenIcon = tokenIcon,
onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) },
onAddClick = { parentRouter?.openAddToPortfolioDirect() },
)
} else {
PortfolioBlockUM.Hidden
}
}
cryptoCurrencyIdState.update {
it ?: allCurrencies.first().currency.id
}
cryptoCurrencyIdState.update { it ?: allCurrencies.first().currency.id }
val totalFiat = allCurrencies.mapNotNull { it.getTotalFiatAmount() }
.fold(BigDecimal.ZERO, BigDecimal::add)
val formattedBalance = totalFiat.formatStyled {
fiat(
fiatCurrencyCode = settings.appCurrency.code,
fiatCurrencySymbol = settings.appCurrency.symbol,
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
}
val firstCurrency = allCurrencies.first().currency
return PortfolioBlockUM.Content(
totalBalance = formattedBalance,
tokensInPortfolioCount = allCurrencies.size,
tokenIcon = tokenIcon,
tokenName = allCurrencies.first().currency.name,
tokenSymbol = allCurrencies.first().currency.symbol,
isBalanceHidden = settings.isBalanceHidden,
onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) },
tokenName = firstCurrency.name,
tokenSymbol = firstCurrency.symbol,
isBalanceHidden = isBalanceHidden,
onRowClick = { parentRouter?.openAddToPortfolioViaUserPortfolio(currencyRawId) },
onAddFundsClick = {},
)
}
}
@ -180,9 +179,4 @@ internal class PortfolioBlockModel @Inject constructor(
private data class WalletPortfolio(
val userWallet: UserWallet,
val currencies: List<CryptoCurrencyStatus>,
)
private data class SettingsBox(
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
)

View file

@ -1,7 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolioblock.model
import com.tangem.core.decompose.navigation.Route
import kotlinx.serialization.Serializable
@Serializable
internal data object PortfolioBlockRoute : Route

View file

@ -22,19 +22,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
@ -95,57 +91,53 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi
@Composable
private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = Modifier) {
FloatingCard(
modifier = modifier,
onClick = state.onClick,
) {
TangemRowContainer(
contentPadding = PaddingValues(0.dp),
FloatingCard(modifier = modifier) { // TODO will be handle in [REDACTED_TASK_KEY]
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
CurrencyIcon(
Row(
modifier = Modifier
.padding(end = TangemTheme.dimens2.x3)
.layoutId(TangemRowLayoutId.HEAD),
state = state.tokenIcon,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = state.tokenName,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = stringResourceSafe(R.string.markets_portfolio_block_subtitle),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP),
text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors2.text.neutral.primary,
style = TangemTheme.typography2.bodyMedium16,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM),
text = state.tokenSymbol,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
.weight(1f)
.clickable(onClick = state.onRowClick),
verticalAlignment = Alignment.CenterVertically,
) {
CurrencyIcon(
modifier = Modifier.padding(end = TangemTheme.dimens2.x3),
state = state.tokenIcon,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.tokenName,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
text = stringResourceSafe(R.string.markets_portfolio_block_subtitle),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors2.text.neutral.primary,
style = TangemTheme.typography2.bodyMedium16,
)
Text(
text = state.tokenSymbol,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
}
}
TangemButton(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x3)
.layoutId(TangemRowLayoutId.TAIL),
modifier = Modifier.padding(start = TangemTheme.dimens2.x3),
buttonUM = TangemButtonUM(
type = TangemButtonType.Secondary,
tangemIconUM = TangemIconUM.Icon(
@ -154,7 +146,7 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M
),
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X9,
onClick = state.onClick,
onClick = state.onAddFundsClick,
),
)
}
@ -163,10 +155,7 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M
@Composable
private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = Modifier) {
FloatingCard(
modifier = modifier,
onClick = state.onClick,
) {
FloatingCard(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
CurrencyIcon(state.tokenIcon)
@ -191,7 +180,7 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier =
text = resourceReference(R.string.common_add),
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X9,
onClick = state.onClick,
onClick = state.onAddClick,
),
)
}
@ -200,7 +189,7 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier =
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FloatingCard(modifier: Modifier = Modifier, onClick: () -> Unit = {}, content: @Composable () -> Unit) {
private fun FloatingCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier = modifier
.navigationBarsPadding()
@ -216,7 +205,6 @@ private fun FloatingCard(modifier: Modifier = Modifier, onClick: () -> Unit = {}
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(size = TangemTheme.dimens2.x5),
)
.clickable(onClick = onClick)
.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3),
) {
content()
@ -236,7 +224,8 @@ private fun ContentPreview() {
tokenName = "Bitcoin",
tokenSymbol = "BTC",
isBalanceHidden = false,
onClick = {},
onRowClick = {},
onAddFundsClick = {},
),
)
}
@ -250,7 +239,7 @@ private fun AddTokenPreview() {
PortfolioBlock(
state = PortfolioBlockUM.AddToken(
tokenIcon = previewCoinIcon,
onClick = {},
onAddClick = {},
),
)
}

View file

@ -12,7 +12,7 @@ internal sealed class PortfolioBlockUM {
data class AddToken(
val tokenIcon: CurrencyIconState,
val onClick: () -> Unit,
val onAddClick: () -> Unit,
) : PortfolioBlockUM()
data class Content(
@ -22,6 +22,7 @@ internal sealed class PortfolioBlockUM {
val tokenName: String,
val tokenSymbol: String,
val isBalanceHidden: Boolean,
val onClick: () -> Unit,
val onRowClick: () -> Unit,
val onAddFundsClick: () -> Unit,
) : PortfolioBlockUM()
}

View file

@ -1,15 +1,15 @@
package com.tangem.features.feed.components.search
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.models.portfolio.UserAssetEntry
internal sealed interface SearchBottomSheetRoute {
data class TokenSelector(
val entries: List<UserAssetSearchEntry>,
val entries: List<UserAssetEntry>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val onTokenSelected: (UserAssetSearchEntry) -> Unit,
val onTokenSelected: (UserAssetEntry) -> Unit,
val onDismiss: () -> Unit,
) : SearchBottomSheetRoute
}

View file

@ -7,9 +7,9 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.models.portfolio.UserAssetEntry
import com.tangem.common.ui.markets.tokenselector.TokenSelectorBottomSheet
import com.tangem.features.feed.model.search.SearchTokenSelectorModel
import com.tangem.features.feed.ui.search.components.TokenSelectorBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@ -37,10 +37,10 @@ internal class SearchTokenSelectorComponent @AssistedInject constructor(
}
data class Params(
val entries: List<UserAssetSearchEntry>,
val entries: List<UserAssetEntry>,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
val onTokenSelected: (UserAssetSearchEntry) -> Unit,
val onTokenSelected: (UserAssetEntry) -> Unit,
val onDismiss: () -> Unit,
)
}

View file

@ -3,9 +3,13 @@ package com.tangem.features.feed.model.market.details
import androidx.compose.runtime.Stable
import arrow.core.Either
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.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.TangemSiteShareUrlBuilder
import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.sorted
@ -36,6 +40,7 @@ import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.markets.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.news.usecase.GetNewsUseCase
@ -43,6 +48,8 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.AddToPortfolioSlotRoute
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.analytics.MarketTokenAnalyticsEvent
import com.tangem.features.feed.impl.R
@ -65,6 +72,7 @@ import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -81,6 +89,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getUserCountryUseCase: GetUserCountryUseCase,
paramsContainer: ParamsContainer,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val designFeatureToggles: DesignFeatureToggles,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
@ -93,6 +102,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
private val urlOpener: UrlOpener,
private val getNewsUseCase: GetNewsUseCase,
private val shareManager: ShareManager,
private val appRouter: AppRouter,
) : Model() {
private val quotesJob = JobHolder()
@ -224,6 +234,13 @@ internal class MarketsTokenDetailsModel @Inject constructor(
val isVisibleOnScreen = MutableStateFlow(false)
val networksState = MutableStateFlow<TokenNetworksState>(TokenNetworksState.Loading)
val addToPortfolioSheetNavigation = SlotNavigation<AddToPortfolioSlotRoute>()
private val isAddToPortfolioAvailable: Boolean =
params.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled
private var addToPortfolioManager: AddToPortfolioManager? = null
private var addToPortfolioListenersJob: Job? = null
val state = MutableStateFlow(
MarketsTokenDetailsUM(
tokenName = params.token.name,
@ -309,6 +326,77 @@ internal class MarketsTokenDetailsModel @Inject constructor(
onListedOnClick(exchangesCount)
}
}
modelScope.launch {
networksState.collect { tokenNetworkState ->
val manager = addToPortfolioManager ?: return@collect
when (tokenNetworkState) {
is TokenNetworksState.NetworksAvailable -> manager.setTokenNetworks(tokenNetworkState.networks)
TokenNetworksState.NoNetworksAvailable -> manager.setTokenNetworks(emptyList())
else -> Unit
}
}
}
}
fun openAddToPortfolio() {
if (!isAddToPortfolioAvailable) return
prepareAddToPortfolioManager(AddToPortfolioManager.LaunchMode.DirectAdd)
addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute)
}
fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) {
if (!isAddToPortfolioAvailable) return
prepareAddToPortfolioManager(AddToPortfolioManager.LaunchMode.ViaUserPortfolio(rawCurrencyId))
addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute)
}
private fun openTokenDetails(result: AddToPortfolioManager.Result) {
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = result.wallet.walletId,
currency = result.addedCurrency.currency,
),
)
}
fun addToPortfolioManagerOrNull(): AddToPortfolioManager? = addToPortfolioManager
private fun prepareAddToPortfolioManager(launchMode: AddToPortfolioManager.LaunchMode) {
addToPortfolioListenersJob?.cancel()
val manager = addToPortfolioManagerFactory.create(
scope = modelScope,
settings = AddToPortfolioManager.Settings(
shouldSkipTokenActionsScreen = false,
launchMode = launchMode,
),
analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source),
)
manager.setTokenParams(params.token)
when (val network = networksState.value) {
is TokenNetworksState.NetworksAvailable -> manager.setTokenNetworks(network.networks)
TokenNetworksState.NoNetworksAvailable -> manager.setTokenNetworks(emptyList())
else -> Unit
}
addToPortfolioManager = manager
addToPortfolioListenersJob = modelScope.launch {
launch {
manager.onDismiss.receiveAsFlow().collect {
addToPortfolioSheetNavigation.dismiss()
}
}
launch {
manager.onSuccessAdded.receiveAsFlow().collect {
addToPortfolioSheetNavigation.dismiss()
}
}
launch {
manager.onAddedTokenClick.receiveAsFlow().collect { result ->
addToPortfolioSheetNavigation.dismiss()
openTokenDetails(result)
}
}
}
}
private fun initialLoad() {

View file

@ -18,7 +18,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.markets.*
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.models.portfolio.UserAssetEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase
import com.tangem.domain.search.usecase.GetSearchResultsUseCase
@ -213,7 +213,7 @@ internal class SearchModel @Inject constructor(
stateController.update(UpdateSearchBarQueryTransformer(""))
}
private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) {
private fun onSingleUserAssetClick(entry: UserAssetEntry) {
searchAnalyticsHelper.sendPortfolioItemClicked(entry.currencyStatus.currency.symbol)
appRouter.push(
AppRoute.CurrencyDetails(
@ -236,7 +236,7 @@ internal class SearchModel @Inject constructor(
)
}
private fun onTokenSelectedFromGroup(entry: UserAssetSearchEntry) {
private fun onTokenSelectedFromGroup(entry: UserAssetEntry) {
bottomSheetNavigation.dismiss()
onSingleUserAssetClick(entry)
}

View file

@ -1,13 +1,13 @@
package com.tangem.features.feed.model.search
import androidx.compose.runtime.Stable
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM
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.feed.components.search.SearchTokenSelectorComponent
import com.tangem.features.feed.model.search.state.TokenSelectorStateController
import com.tangem.features.feed.model.search.state.transformers.BuildTokenSelectorSectionsTransformer
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject

View file

@ -1,12 +1,14 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.extensions.networkIconResId
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState
import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
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.common.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
@ -14,10 +16,8 @@ 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.currency.CryptoCurrencyStatus
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.models.portfolio.UserAssetEntry
import com.tangem.domain.search.model.UserAssetSearchItem
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
@ -26,7 +26,7 @@ import java.math.BigDecimal
internal class UserAssetSearchItemConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onSingleClick: (UserAssetSearchEntry) -> Unit,
private val onSingleClick: (UserAssetEntry) -> Unit,
private val onGroupedClick: (UserAssetSearchItem.Grouped) -> Unit,
) : Converter<UserAssetSearchItem, UserAssetItemUM> {
@ -37,7 +37,7 @@ internal class UserAssetSearchItemConverter(
}
}
private fun convertSingle(entry: UserAssetSearchEntry): UserAssetItemUM.Single {
private fun convertSingle(entry: UserAssetEntry): UserAssetItemUM.Single {
val currency = entry.currencyStatus.currency
val value = entry.currencyStatus.value
@ -124,7 +124,7 @@ internal class UserAssetSearchItemConverter(
}
private fun convertGroupedBalanceState(
entries: List<UserAssetSearchEntry>,
entries: List<UserAssetEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState {
@ -145,7 +145,7 @@ internal class UserAssetSearchItemConverter(
}
private fun computeGroupBalance(
entries: List<UserAssetSearchEntry>,
entries: List<UserAssetEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState.Loaded {
@ -158,7 +158,7 @@ internal class UserAssetSearchItemConverter(
}
private fun computeGroupBalanceFlickering(
entries: List<UserAssetSearchEntry>,
entries: List<UserAssetEntry>,
symbol: String,
decimals: Int,
): BalanceDisplayState.Flickering {

View file

@ -1,8 +1,8 @@
package com.tangem.features.feed.model.search.state
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.features.feed.model.search.state.transformers.TokenSelectorUMTransformer
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

View file

@ -1,18 +1,18 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.markets.tokenselector.AccountHeaderData
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM
import com.tangem.common.ui.markets.tokenselector.TokenSelectorSectionUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.AccountHeaderData
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import com.tangem.domain.models.portfolio.UserAssetEntry
import kotlinx.collections.immutable.toImmutableList
internal class BuildTokenSelectorSectionsTransformer(
private val entries: List<UserAssetSearchEntry>,
private val entries: List<UserAssetEntry>,
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenSelected: (UserAssetSearchEntry) -> Unit,
private val onTokenSelected: (UserAssetEntry) -> Unit,
) : TokenSelectorUMTransformer {
private val entryConverter = TokenSelectorEntryConverter(

View file

@ -1,6 +1,8 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState
import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.image.TangemIconUM
@ -11,9 +13,7 @@ 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.currency.CryptoCurrencyStatus
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import com.tangem.domain.models.portfolio.UserAssetEntry
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
@ -22,12 +22,12 @@ import java.math.BigDecimal
internal class TokenSelectorEntryConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val onTokenSelected: (UserAssetSearchEntry) -> Unit,
) : Converter<UserAssetSearchEntry, UserAssetItemUM.Single> {
private val onTokenSelected: (UserAssetEntry) -> Unit,
) : Converter<UserAssetEntry, UserAssetItemUM.Single> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: UserAssetSearchEntry): UserAssetItemUM.Single {
override fun convert(value: UserAssetEntry): UserAssetItemUM.Single {
val currency = value.currencyStatus.currency
val currencyValue = value.currencyStatus.value

View file

@ -1,6 +1,6 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM
internal interface TokenSelectorUMTransformer {
fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM

View file

@ -1,9 +1,9 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import kotlinx.collections.immutable.ImmutableList
internal class UpdateUserAssetsTransformer(

View file

@ -22,6 +22,9 @@ import androidx.compose.ui.unit.dp
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.tokenselector.GroupedUserAssetItem
import com.tangem.common.ui.markets.tokenselector.SingleUserAssetItem
import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
@ -33,8 +36,6 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem
import com.tangem.features.feed.ui.search.components.SingleUserAssetItem
import com.tangem.features.feed.ui.search.state.*
import kotlinx.collections.immutable.ImmutableList

View file

@ -11,6 +11,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState
import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.components.marketprice.PriceChangeType

View file

@ -19,10 +19,10 @@ import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem
import com.tangem.features.feed.ui.search.components.SingleUserAssetItem
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState
import com.tangem.common.ui.markets.tokenselector.GroupedUserAssetItem
import com.tangem.common.ui.markets.tokenselector.SingleUserAssetItem
import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM
/** Labeled UI state for [SingleUserAssetItem] previews (dropdown label in Studio). */
internal data class SingleUserAssetItemPreviewScenario(

View file

@ -2,10 +2,8 @@ package com.tangem.features.feed.ui.search.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
data class SearchUM(
@ -43,59 +41,4 @@ sealed interface MarketSearchResultUM {
data object Empty : MarketSearchResultUM
}
data class TextHintItemUM(val text: String)
@Immutable
sealed interface BalanceDisplayState {
data class Loaded(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data class Flickering(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data class Stale(
val cryptoBalance: TextReference,
val fiatBalance: TextReference,
) : BalanceDisplayState
data object Loading : BalanceDisplayState
data object Unreachable : BalanceDisplayState
}
@Immutable
sealed interface UserAssetItemUM {
val id: String
val icon: TangemIconUM
val tokenName: String
val tokenSymbol: String
val onClick: () -> Unit
data class Single(
override val id: String,
override val icon: TangemIconUM,
override val tokenName: String,
override val tokenSymbol: String,
val fiatRate: String?,
val priceChangeState: PriceChangeState,
val balanceState: BalanceDisplayState,
val isBalanceHidden: Boolean,
val networkName: String,
override val onClick: () -> Unit,
) : UserAssetItemUM
data class Grouped(
override val id: String,
override val icon: TangemIconUM,
override val tokenName: String,
override val tokenSymbol: String,
val tokensCount: Int,
val balanceState: BalanceDisplayState,
val isBalanceHidden: Boolean,
override val onClick: () -> Unit,
) : UserAssetItemUM
}
data class TextHintItemUM(val text: String)

View file

@ -8,9 +8,9 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM
import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM
import com.tangem.domain.models.portfolio.UserAssetEntry
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM
import com.tangem.common.ui.markets.tokenselector.TokenSelectorSectionUM
import io.mockk.*
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.BeforeEach
@ -22,7 +22,7 @@ import java.math.BigDecimal
class BuildTokenSelectorSectionsTransformerTest {
private val appCurrency: AppCurrency = AppCurrency.Default
private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true)
private val onTokenSelected: (UserAssetEntry) -> Unit = mockk(relaxed = true)
private val prevState = TokenSelectorContentUM(sections = persistentListOf())
@BeforeEach
@ -231,7 +231,7 @@ class BuildTokenSelectorSectionsTransformerTest {
// region Helpers
private fun createTransformer(entries: List<UserAssetSearchEntry>): BuildTokenSelectorSectionsTransformer {
private fun createTransformer(entries: List<UserAssetEntry>): BuildTokenSelectorSectionsTransformer {
return BuildTokenSelectorSectionsTransformer(
entries = entries,
appCurrency = appCurrency,
@ -259,7 +259,7 @@ class BuildTokenSelectorSectionsTransformerTest {
currencyId: String = "btc",
currencyName: String = "Bitcoin",
currencySymbol: String = "BTC",
): UserAssetSearchEntry {
): UserAssetEntry {
val network = mockk<Network>(relaxed = true) {
every { name } returns "Network"
}
@ -290,7 +290,7 @@ class BuildTokenSelectorSectionsTransformerTest {
sources = CryptoCurrencyStatus.Sources(),
)
}
return mockk<UserAssetSearchEntry> {
return mockk<UserAssetEntry> {
every { userWalletId } returns walletId
every { userWalletName } returns walletName
every { this@mockk.accountId } returns accountId

View file

@ -8,8 +8,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.features.feed.ui.search.state.BalanceDisplayState
import com.tangem.domain.models.portfolio.UserAssetEntry
import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
@ -23,7 +23,7 @@ import java.math.BigDecimal
class TokenSelectorEntryConverterTest {
private val appCurrency: AppCurrency = AppCurrency.Default
private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true)
private val onTokenSelected: (UserAssetEntry) -> Unit = mockk(relaxed = true)
private lateinit var converter: TokenSelectorEntryConverter
@ -276,7 +276,7 @@ class TokenSelectorEntryConverterTest {
fiatRate = BigDecimal("30000.0"),
priceChange = BigDecimal("1.0"),
),
): UserAssetSearchEntry {
): UserAssetEntry {
val userWalletId = mockk<UserWalletId> {
every { stringValue } returns walletId
}
@ -302,7 +302,7 @@ class TokenSelectorEntryConverterTest {
every { this@mockk.currency } returns currency
every { this@mockk.value } returns value
}
return mockk<UserAssetSearchEntry> {
return mockk<UserAssetEntry> {
every { this@mockk.userWalletId } returns userWalletId
every { this@mockk.userWalletName } returns "Wallet"
every { this@mockk.accountId } returns accountIdMock