Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-30 10:36:00 +05:00
commit d036b23ba4
1274 changed files with 51752 additions and 15452 deletions

View file

@ -0,0 +1,121 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
@Composable
internal fun AddToPortfolioBottomSheet(
childStack: State<ChildStack<AddToPortfolioRoutes, ComposableContentComponent>>,
onBack: () -> Unit,
onDismiss: () -> Unit,
) {
val stack by childStack
val contentStack = remember { mutableStateOf(stack) }
val currentRoute = stack.active.configuration
val isNotEmpty = currentRoute != AddToPortfolioRoutes.Empty
if (isNotEmpty) {
contentStack.value = stack
}
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
scrollableContent = false,
onBack = onBack,
config = TangemBottomSheetConfig(
isShown = isNotEmpty,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors.background.tertiary,
title = {
AnimatedContent(targetState = contentStack.value, label = "Title Animation") { animatedStack ->
AddToPortfolioBottomSheetTitle(
stack = animatedStack,
onBackClick = onBack,
modifier = Modifier.fillMaxWidth(),
)
}
},
content = {
AnimatedContent(targetState = contentStack.value, label = "Content Animation") { animatedStack ->
val paddingModifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
)
val isScrollableContent = when (animatedStack.active.configuration) {
AddToPortfolioRoutes.PortfolioSelector,
AddToPortfolioRoutes.UserPortfolio,
-> false
AddToPortfolioRoutes.AddToken,
AddToPortfolioRoutes.Empty,
is AddToPortfolioRoutes.NetworkSelector,
AddToPortfolioRoutes.TokenActions,
-> true
}
if (isScrollableContent) {
Column(
modifier = paddingModifier.verticalScroll(rememberScrollState()),
) {
animatedStack.active.instance.Content(modifier = Modifier)
}
} else {
animatedStack.active.instance.Content(modifier = paddingModifier)
}
}
},
)
}
@Composable
private fun AddToPortfolioBottomSheetTitle(
stack: ChildStack<AddToPortfolioRoutes, ComposableContentComponent>,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title: TextReference = when (stack.active.configuration) {
AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token)
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
}
val startIconRes: Int?
val endIconRes: Int?
if (stack.backStack.isNotEmpty()) {
startIconRes = R.drawable.ic_back_24
endIconRes = null
} else {
startIconRes = null
endIconRes = R.drawable.ic_close_24
}
TangemModalBottomSheetTitle(
modifier = modifier,
title = title,
startIconRes = startIconRes,
endIconRes = endIconRes,
onStartClick = onBackClick,
onEndClick = onBackClick,
)
}

View file

@ -0,0 +1,110 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.style.TextOverflow
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioFooterKind
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
import dev.chrisbanes.haze.rememberHazeState
@Composable
internal fun AddToPortfolioBottomSheetFooter(
currentRoute: AddToPortfolioRoutes,
userPortfolioState: State<UserPortfolioUM?>?,
onBack: () -> Unit,
onAddFromUserPortfolioClick: (() -> Unit)?,
) {
when (currentRoute.uiSpec().footer) {
AddToPortfolioFooterKind.Cancel -> WithLocalHaze {
CancelFooterButton(onClick = onBack)
}
AddToPortfolioFooterKind.UserPortfolioAdd -> {
val state = userPortfolioState?.value ?: return
val onClick = onAddFromUserPortfolioClick ?: return
WithLocalHaze {
UserPortfolioAddFooter(
isEnabled = state.isAddEnabled,
onClick = onClick,
)
}
}
AddToPortfolioFooterKind.None -> Unit
}
}
@Composable
private fun WithLocalHaze(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalHazeState provides rememberHazeState(), content = content)
}
@Composable
private fun CancelFooterButton(onClick: () -> Unit) {
SecondaryTangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(bottom = TangemTheme.dimens2.x4),
onClick = onClick,
text = resourceReference(R.string.common_cancel),
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
}
@Composable
private fun UserPortfolioAddFooter(isEnabled: Boolean, onClick: () -> Unit) {
TangemRowContainer(
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens2.x6,
vertical = TangemTheme.dimens2.x5,
),
) {
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = stringResourceSafe(R.string.common_add_token),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = stringResourceSafe(R.string.markets_token_add_subtitle),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SecondaryTangemButton(
modifier = Modifier
.layoutId(TangemRowLayoutId.TAIL)
.padding(start = TangemTheme.dimens2.x2),
onClick = onClick,
text = resourceReference(R.string.common_add),
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
isEnabled = isEnabled,
)
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
@Composable
internal fun AddToPortfolioBottomSheetSwitch(
childStack: State<ChildStack<AddToPortfolioRoutes, ComposableContentComponent>>,
onBack: () -> Unit,
onDismiss: () -> Unit,
userPortfolioState: State<UserPortfolioUM?>? = null,
onAddFromUserPortfolioClick: (() -> Unit)? = null,
) {
if (LocalRedesignEnabled.current) {
AddToPortfolioBottomSheetV2(
childStack = childStack,
onBack = onBack,
onDismiss = onDismiss,
userPortfolioState = userPortfolioState,
onAddFromUserPortfolioClick = onAddFromUserPortfolioClick,
)
} else {
AddToPortfolioBottomSheet(
childStack = childStack,
onBack = onBack,
onDismiss = onDismiss,
)
}
}

View file

@ -0,0 +1,126 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.*
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM
@Composable
internal fun AddToPortfolioBottomSheetV2(
childStack: State<ChildStack<AddToPortfolioRoutes, ComposableContentComponent>>,
onBack: () -> Unit,
onDismiss: () -> Unit,
userPortfolioState: State<UserPortfolioUM?>? = null,
onAddFromUserPortfolioClick: (() -> Unit)? = null,
) {
val stack by childStack
val contentStack = remember { mutableStateOf(stack) }
val isNotEmpty = stack.active.configuration != AddToPortfolioRoutes.Empty
if (isNotEmpty) {
contentStack.value = stack
}
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
onBack = onBack,
config = TangemBottomSheetConfig(
isShown = isNotEmpty,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors2.surface.level2,
title = {
AddToPortfolioBottomSheetTitle(
stack = stack,
onCloseClick = onDismiss,
)
},
content = {
AnimatedContent(targetState = contentStack.value, label = "Content Animation") { animatedStack ->
AddToPortfolioRouteContent(animatedStack = animatedStack)
}
},
footer = {
AnimatedContent(
targetState = contentStack.value.active.configuration,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "Footer Animation",
) { route ->
AddToPortfolioBottomSheetFooter(
currentRoute = route,
userPortfolioState = userPortfolioState,
onBack = onBack,
onAddFromUserPortfolioClick = onAddFromUserPortfolioClick,
)
}
},
)
}
@Composable
private fun AddToPortfolioRouteContent(animatedStack: ChildStack<AddToPortfolioRoutes, ComposableContentComponent>) {
val spec = animatedStack.active.configuration.uiSpec()
val baseModifier = if (spec.shouldApplyHorizontalPadding) {
Modifier.padding(horizontal = TangemTheme.dimens2.x4)
} else {
Modifier
}
if (spec.isScrollable) {
val bottomInset = LocalTangemBottomSheetContentBottomInset.current
val scrollBottomReserve = if (bottomInset > 0.dp) bottomInset else TangemTheme.dimens2.x4
Column(modifier = baseModifier.verticalScroll(rememberScrollState())) {
animatedStack.active.instance.Content(modifier = Modifier)
Spacer(modifier = Modifier.height(scrollBottomReserve))
}
} else {
animatedStack.active.instance.Content(modifier = baseModifier)
}
}
@Composable
private fun AddToPortfolioBottomSheetTitle(
stack: ChildStack<AddToPortfolioRoutes, ComposableContentComponent>,
onCloseClick: () -> Unit,
) {
TangemTopBar(
title = stack.active.configuration.uiSpec().title,
type = TangemTopBarType.BottomSheet,
endContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_close_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(onClick = onCloseClick)
.padding(TangemTheme.dimens2.x2),
)
},
)
}

View file

@ -0,0 +1,64 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.addtoken.AddTokenContent
import com.tangem.common.ui.addtoken.AddTokenContentV2
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class AddTokenComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableContentComponent {
private val model: AddTokenModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val um = state.value ?: return
if (LocalRedesignEnabled.current) {
AddTokenContentV2(
modifier = modifier,
state = um,
)
} else {
AddTokenContent(
modifier = modifier,
state = um,
)
}
}
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val selectedPortfolio: Flow<SelectedPortfolio>,
val selectedNetwork: Flow<SelectedNetwork>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onChangeNetworkClick()
fun onChangePortfolioClick()
fun onTokenAdded(status: CryptoCurrencyStatus)
}
@AssistedFactory
interface Factory : ComponentFactory<Params, AddTokenComponent> {
override fun create(context: AppComponentContext, params: Params): AddTokenComponent
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.ChooseNetworkContent
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class ChooseNetworkComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : AppComponentContext by context, ComposableContentComponent {
private val model: ChooseNetworkModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
ChooseNetworkContent(state)
}
data class Params(
val selectedPortfolio: SelectedPortfolio,
val callbacks: Callbacks,
)
interface Callbacks {
fun onNetworkSelected(network: TokenMarketInfo.Network)
}
@AssistedFactory
interface Factory : ComponentFactory<Params, ChooseNetworkComponent> {
override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent
}
}

View file

@ -0,0 +1,133 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.backStack
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
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,
portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
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)
private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create(
context = child("portfolioSelectorComponent"),
params = PortfolioSelectorComponent.Params(
portfolioFetcher = model.addToPortfolioManager.portfolioFetcher,
controller = model.portfolioSelectorController,
),
)
private val addTokenComponent: AddTokenComponent by lazy {
addTokenComponentFactory.create(
context = child("addTokenComponent"),
params = AddTokenComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
selectedPortfolio = model.selectedPortfolio,
selectedNetwork = model.selectedNetwork,
),
)
}
private val tokenActionsComponent: TokenActionsComponent by lazy {
tokenActionsComponentFactory.create(
context = child("tokenActionsComponent"),
params = TokenActionsComponent.Params(
eventBuilder = model.eventBuilder,
callbacks = model,
data = model.tokenActionsData,
),
)
}
private val childStack = childStack(
key = "addToPortfolioStack",
handleBackButton = true,
source = model.navigation,
serializer = AddToPortfolioRoutes.serializer(),
initialStack = { model.currentStack },
childFactory = ::contentChild,
)
private fun onBack() {
if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss()
}
override fun dismiss() {
model.addToPortfolioManager.onDismiss()
}
@Composable
override fun BottomSheet() {
val userPortfolioState = model.userPortfolioStateController.uiState
.collectAsStateWithLifecycle()
AddToPortfolioBottomSheetSwitch(
childStack = childStack.subscribeAsState(),
onBack = ::onBack,
onDismiss = ::dismiss,
userPortfolioState = userPortfolioState,
onAddFromUserPortfolioClick = model::onContinueFromUserPortfolio,
)
}
private fun contentChild(
config: AddToPortfolioRoutes,
componentContext: ComponentContext,
): ComposableContentComponent = when (config) {
AddToPortfolioRoutes.AddToken -> addTokenComponent
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(
selectedPortfolio = config.selectedPortfolio,
callbacks = model,
),
)
}
private fun createUserPortfolioComponent(componentContext: ComponentContext): ComposableContentComponent {
return userPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = UserPortfolioComponent.Params(
uiState = model.userPortfolioStateController.uiState,
callbacks = model,
),
)
}
@AssistedFactory
interface Factory : AddToPortfolioComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddToPortfolioComponent.Params,
): DefaultAddToPortfolioComponent
}
}

View file

@ -0,0 +1,88 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
import androidx.compose.runtime.Composable
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.common.ui.markets.action.CryptoCurrencyData
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContentV2
import com.tangem.features.tokenreceive.TokenReceiveComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
internal class TokenActionsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : AppComponentContext by context, ComposableContentComponent {
private val model: TokenActionsModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TokenReceiveConfig.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state = model.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
val tokenActionsUM = state.value ?: return
if (LocalRedesignEnabled.current) {
TokenActionsContentV2(
modifier = modifier,
state = tokenActionsUM,
)
} else {
TokenActionsContent(
modifier = modifier,
state = tokenActionsUM,
)
}
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
config: TokenReceiveConfig,
componentContext: ComponentContext,
): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create(
context = childByContext(componentContext),
params = TokenReceiveComponent.Params(
config = config,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
data class Params(
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder,
val data: Flow<CryptoCurrencyData>,
val callbacks: Callbacks,
)
interface Callbacks {
fun onLaterClick()
}
@AssistedFactory
interface Factory : ComponentFactory<Params, TokenActionsComponent> {
override fun create(context: AppComponentContext, params: Params): TokenActionsComponent
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
// todo swap unify with PortfolioAnalyticsEvent, AddToPortfolioFlow
internal sealed class EarnAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Earn", event = event, params = params) {
data class AddTokenScreenOpened(
private val tokenSymbol: String,
private val blockchain: String,
private val source: String,
) : EarnAnalyticsEvent(
event = "Add Token Screen Opened",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.BLOCKCHAIN to blockchain,
AnalyticsParam.SOURCE to source,
),
)
data class TokenAdded(
private val tokenSymbol: String,
private val blockchain: String,
) : EarnAnalyticsEvent(
event = "Token Added",
params = mapOf(
AnalyticsParam.TOKEN_PARAM to tokenSymbol,
AnalyticsParam.BLOCKCHAIN to blockchain,
),
)
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
internal class PortfolioAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
category: String,
) : AnalyticsEvent(category = category, event = event, params = params) {
data class EventBuilder(
val tokenSymbol: String,
val source: String?,
val category: String,
) {
fun popupToChooseAccount() = PortfolioAnalyticsEvent(
event = "Choose Account Opened",
category = category,
params = buildMap {
if (source != null) put(AnalyticsParam.SOURCE, source)
},
)
fun popupToConfirm(blockchain: String) = PortfolioAnalyticsEvent(
event = "Add Token Screen Opened",
category = category,
params = buildMap {
put(AnalyticsParam.TOKEN_PARAM, tokenSymbol)
put(AnalyticsParam.BLOCKCHAIN, blockchain)
if (source != null) put(AnalyticsParam.SOURCE, source)
},
)
fun addToNotMainAccount() = PortfolioAnalyticsEvent(
event = "Button - Add To Account",
category = category,
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addButtonClick() = PortfolioAnalyticsEvent(
event = "Button - Add Token",
category = category,
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(
event = "Wallet Selected",
category = category,
params = buildMap {
if (source != null) put("Source", source)
},
)
fun addToPortfolioContinue(blockchainNames: List<String>) = PortfolioAnalyticsEvent(
event = "Token Network Selected",
category = category,
params = buildMap {
put("Count", blockchainNames.size.toString())
put("Token", tokenSymbol)
put("blockchain", blockchainNames.joinToString(separator = ", "))
if (source != null) put("Source", source)
},
)
fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent(
event = "Token Added",
category = category,
params = buildMap {
put(AnalyticsParam.TOKEN_PARAM, tokenSymbol)
put(AnalyticsParam.BLOCKCHAIN, blockchainName)
if (source != null) put("Source", source)
},
)
fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent(
event = when (actionUM) {
TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy"
TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive"
TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange"
TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake"
else -> "error"
},
category = category,
params = buildMap {
if (source != null) put("Source", source)
},
)
fun getTokenLater() = PortfolioAnalyticsEvent(
event = "Popup Get token - Button Later",
category = category,
params = buildMap {
if (source != null) put("Source", source)
},
)
}
}

View file

@ -0,0 +1,116 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.converter
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.RawMarketToken
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
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.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
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 AvailableToAddDataConverter @Inject constructor(
private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
) {
suspend fun convert(
balances: Map<UserWalletId, PortfolioFetcher.PortfolioBalance>,
availableNetworks: Set<TokenMarketInfo.Network>,
marketParams: RawMarketToken,
): AvailableToAddData {
suspend fun AccountStatus.CryptoPortfolio.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? {
val currencies = availableNetworks
.mapNotNull { network ->
createCryptoCurrency(
userWallet = wallet,
network = network,
marketParams = marketParams,
account = this.account,
)
}
if (currencies.isEmpty()) return null
val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies)
.fold(
ifEmpty = { emptySet() },
ifSome = { map ->
map.values.flatMapTo(hashSetOf()) { statuses ->
statuses.map { it.currency.network }
}
},
)
return AvailableToAddAccount(
account = this,
availableNetworks = availableNetworks,
addedNetworks = addedNetworks,
)
}
suspend fun getAvailableToAddWallet(
entry: Map.Entry<UserWalletId, PortfolioFetcher.PortfolioBalance>,
): AvailableToAddWallet {
val (_, balance) = entry
val wallet = balance.userWallet
val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks)
val accounts = balance.accountsBalance.accountStatuses.filterCryptoPortfolio()
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount> = accounts
.mapNotNull { accountStatus ->
val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null
accountStatus.account.accountId to availableToAddAccount
}
.toMap()
return AvailableToAddWallet(
userWallet = wallet,
accounts = accounts,
availableNetworks = filteredNetworks,
availableToAddAccounts = availableToAddAccounts,
)
}
val availableToAddWallets: Map<UserWalletId, AvailableToAddWallet> = balances
.map { entry ->
val (walletId, _) = entry
val availableToAddWallet = getAvailableToAddWallet(entry)
walletId to availableToAddWallet
}
.filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() }
.toMap()
return AvailableToAddData(
availableToAddWallets = availableToAddWallets,
)
}
private fun UserWallet.filteredAvailableNetworks(networks: Set<TokenMarketInfo.Network>) =
filterAvailableNetworksForWalletUseCase(
userWalletId = this.walletId,
networks = networks,
)
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
marketParams: RawMarketToken,
account: Account.CryptoPortfolio,
): CryptoCurrency? {
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = marketParams,
network = network,
accountIndex = account.derivationIndex,
)
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.converter
import com.tangem.common.ui.extensions.greyedOutIconResId
import com.tangem.common.ui.extensions.iconResId
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.network.Network
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
/**
* Converter from [com.tangem.domain.markets.TokenMarketInfo.Network] to [com.tangem.core.ui.components.rows.model.BlockchainRowUM]
*
* @property alreadyAddedNetworks set of already added networks
*
[REDACTED_AUTHOR]
*/
internal class BlockchainRowUMConverter(
private val alreadyAddedNetworks: Set<String>,
) : Converter<Pair<TokenMarketInfo.Network, Boolean>, BlockchainRowUM> {
override fun convert(value: Pair<TokenMarketInfo.Network, Boolean>): BlockchainRowUM {
val (network, isSelected) = value
val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId)
?: error("Can't find blockchain info for ${network.networkId}")
val isMainNetwork = network.contractAddress == null
val isEnabled = !alreadyAddedNetworks.contains(network.networkId)
val networkRawId = Network.RawID(value = network.networkId)
return BlockchainRowUM(
id = network.networkId,
name = blockchainInfo.name,
type = getNetworkType(network, blockchainInfo),
iconResId = if (isEnabled) {
if (isSelected) {
networkRawId.iconResId
} else {
networkRawId.greyedOutIconResId
}
} else {
networkRawId.greyedOutIconResId
},
isMainNetwork = isMainNetwork,
isSelected = isSelected,
isEnabled = isEnabled,
)
}
private fun getNetworkType(
network: TokenMarketInfo.Network,
blockchainInfo: BlockchainUtils.BlockchainInfo,
): String {
val isMainNetwork = network.contractAddress == null
return when {
BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME
isMainNetwork -> MAIN_NETWORK_TYPE_NAME
else -> blockchainInfo.protocolName
}
}
private companion object {
const val MAIN_NETWORK_TYPE_NAME = "MAIN"
const val MAIN_NETWORK_L2_TYPE_NAME = "MAIN L2"
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.di
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent
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
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface AddToPortfolioComponentModule {
@Binds
fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory
@Binds
fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory
@Binds
fun bindUserPortfolioComponentFactory(
factory: DefaultUserPortfolioComponent.Factory,
): UserPortfolioComponent.Factory
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel
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
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface AddToPortfolioModelModule {
@Binds
@IntoMap
@ClassKey(AddTokenModel::class)
fun addTokenModel(model: AddTokenModel): Model
@Binds
@IntoMap
@ClassKey(AddToPortfolioModel::class)
fun addToPortfolioModel(model: AddToPortfolioModel): Model
@Binds
@IntoMap
@ClassKey(TokenActionsModel::class)
fun tokenActionsModel(model: TokenActionsModel): Model
@Binds
@IntoMap
@ClassKey(ChooseNetworkModel::class)
fun chooseNetworkModel(model: ChooseNetworkModel): Model
@Binds
@IntoMap
@ClassKey(UserPortfolioModel::class)
fun userPortfolioModel(model: UserPortfolioModel): Model
}

View file

@ -0,0 +1,152 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import arrow.core.getOrElse
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.RawMarketToken
import com.tangem.domain.markets.TokenMarketInfo
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: RawMarketToken,
accountToAdd: AvailableToAddAccount? = null,
preferredNetwork: TokenMarketInfo.Network? = 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 = pickNetworkForExplicitAccount(
userWallet = ownerWallet.userWallet,
account = accountToAdd,
orderedNetworks = orderedNetworks,
tokenParams = tokenParams,
preferredNetwork = preferredNetwork,
)
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 pickNetworkForExplicitAccount(
userWallet: UserWallet,
account: AvailableToAddAccount,
orderedNetworks: List<TokenMarketInfo.Network>,
tokenParams: RawMarketToken,
preferredNetwork: TokenMarketInfo.Network?,
): TokenMarketInfo.Network {
if (preferredNetwork != null) {
val candidate = account.availableToAddNetworks
.firstOrNull { it.networkId == preferredNetwork.networkId }
if (
candidate != null && hasDerivationFor(
userWallet = userWallet,
account = account,
network = candidate,
tokenParams = tokenParams,
)
) {
return candidate
}
}
return pickAddableNetwork(
userWallet = userWallet,
account = account,
orderedNetworks = orderedNetworks,
tokenParams = tokenParams,
) ?: orderedNetworks.first()
}
private suspend fun pickAddableNetwork(
userWallet: UserWallet,
account: AvailableToAddAccount,
orderedNetworks: List<TokenMarketInfo.Network>,
tokenParams: RawMarketToken,
): TokenMarketInfo.Network? {
val availableOrdered = orderedNetworks.filter { candidate ->
account.availableToAddNetworks.any { it.networkId == candidate.networkId }
}
if (availableOrdered.isEmpty()) return null
val withDerivation = availableOrdered.firstOrNull { network ->
hasDerivationFor(userWallet = userWallet, account = account, network = network, tokenParams = tokenParams)
}
return withDerivation ?: availableOrdered.first()
}
private suspend fun hasDerivationFor(
userWallet: UserWallet,
account: AvailableToAddAccount,
network: TokenMarketInfo.Network,
tokenParams: RawMarketToken,
): Boolean {
val derivationIndex = account.account.account.derivationIndex
val currency = getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = tokenParams,
network = network,
accountIndex = derivationIndex,
) ?: return false
return networkHasDerivationUseCase(userWallet, currency.network).getOrElse { false }
}
data class InitialSelection(
val userWallet: UserWallet,
val account: AvailableToAddAccount,
val network: TokenMarketInfo.Network,
)
}

View file

@ -0,0 +1,566 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
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
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.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.markets.GetTokenMarketCryptoCurrency
import com.tangem.domain.markets.RawMarketToken
import com.tangem.domain.markets.TokenMarketInfo
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.PortfolioSelectorController
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
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val TOKEN_ACTIONS_DELAY = 500L
@ModelScoped
@Suppress("LongParameterList", "LargeClass")
internal class AddToPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
val portfolioSelectorController: PortfolioSelectorController,
private val designFeatureToggles: DesignFeatureToggles,
private val callbackDelegate: AddToPortfolioCallbackDelegate,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency,
private val messageSender: UiMessageSender,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val selectionResolver: AddToPortfolioInitialSelectionResolver,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
userPortfolioStateControllerFactory: UserPortfolioStateController.Factory,
) : Model(),
ChooseNetworkComponent.Callbacks by callbackDelegate,
TokenActionsComponent.Callbacks by callbackDelegate,
AddTokenComponent.Callbacks by callbackDelegate,
UserPortfolioComponent.Callbacks by callbackDelegate {
private val params = paramsContainer.require<AddToPortfolioComponent.Params>()
val navigation = StackNavigation<AddToPortfolioRoutes>()
var currentStack = listOf<AddToPortfolioRoutes>(AddToPortfolioRoutes.Empty)
/* Flows that hold state and provide it to child models */
val selectedNetwork: MutableSharedFlow<SelectedNetwork> = replayMutableSharedFlow()
val selectedPortfolio: MutableSharedFlow<SelectedPortfolio> = replayMutableSharedFlow()
val tokenActionsData: MutableSharedFlow<CryptoCurrencyData> = replayMutableSharedFlow()
val addToPortfolioManager: AddToPortfolioManager = params.addToPortfolioManager
val paramsSnapshot: AddToPortfolioManager.Params by lazy {
addToPortfolioManager.paramsFlow.replayCache.first()
}
val eventBuilder: PortfolioAnalyticsEvent.EventBuilder by lazy {
val tokenMarketParams = paramsSnapshot.token
PortfolioAnalyticsEvent.EventBuilder(
tokenSymbol = tokenMarketParams.symbol,
source = addToPortfolioManager.analyticsParams.source,
category = addToPortfolioManager.analyticsParams.category,
)
}
val userPortfolioStateController = userPortfolioStateControllerFactory.create(
modelScope = modelScope,
onTokenSelected = { result -> addToPortfolioManager.onAddedTokenClick(result) },
)
private val globalSelectedWallet: UserWallet?
get() = getSelectedWalletSyncUseCase().getOrNull()
.takeIf { it?.isMultiCurrency == true }
init {
navigation.subscribe { currentStack = it.transformer.invoke(currentStack) }
startRedesignAddToPortfolioFlow()
}
private fun <T> replayMutableSharedFlow() = MutableSharedFlow<T>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private fun lineNavigationFlowToAddTokenScreen(
isAccountMode: Boolean,
data: AvailableToAddData,
firstSelectedPortfolioFlow: Flow<SelectedPortfolio>,
): Flow<SelectedPortfolio> {
val isSinglePortfolio = data.isSinglePortfolio
if (isSinglePortfolio) {
val accountId = data.availableToAddWallets.values.first()
.availableToAddAccounts.values.first()
.account.account.accountId
// force select a portfolio, triggers [selectedPortfolio]
portfolioSelectorController.selectAccount(accountId)
} else {
logAccountSelector(isAccountMode)
navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector)
}
return firstSelectedPortfolioFlow.onEach { portfolio ->
val isAvailableToAdd = portfolio.account.isAvailableToAdd
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
when {
// force select an added network, not allowed to add
// but its call AddToPortfolioManager.onAddedTokenClick callback
!isAvailableToAdd -> {
val singleNetwork = portfolio.account.addedMarketNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
}
// force select a network, triggers [selectedNetwork]
isSingleAvailableNetwork -> {
val singleNetwork = portfolio.account.availableToAddNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
}
// it's important to control root screen, UI depends on it(close/arrow icon)
isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio))
else -> navigation.pushNew(routeToNetworkSelector(portfolio))
}
}
}
@Suppress("LongMethod")
private fun startRedesignAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishSuccessFlow(result: AddToPortfolioManager.Result) {
addToPortfolioManager.onSuccessAdded(result)
channel.close()
}
fun finishOnAddedTokenClick(result: AddToPortfolioManager.Result) {
addToPortfolioManager.onAddedTokenClick(result)
channel.close()
}
fun finishDismissFlow() {
addToPortfolioManager.onDismiss()
channel.close()
}
val tokenMarketParams = paramsSnapshot.token
val launchMode = paramsSnapshot.launchMode
val isAccountMode = portfolioSelectorController.isAccountModeSync()
val initialData: AvailableToAddData = addToPortfolioManager.state
.filterIsInstance<AddToPortfolioManager.State.Ready>()
.map { it.availableToAddData }
.first()
setupPortfolioSelector(initialData, launchMode)
val shouldShowUserPortfolio = designFeatureToggles.isRedesignEnabled &&
launchMode is AddToPortfolioManager.LaunchMode.ViaUserPortfolio &&
initialData.hasAnyAddedCurrency(tokenMarketParams.id)
if (shouldShowUserPortfolio) {
// suspend, must prepare UM before navigate to UserPortfolio
userPortfolioStateController.updateAndWaitNotNullState(
allAvailableData = initialData,
rawCurrencyId = tokenMarketParams.id,
)
navigation.replaceAll(AddToPortfolioRoutes.UserPortfolio)
callbackDelegate.onContinueFromUserPortfolio.receiveAsFlow().first()
}
val initialSelection: AddToPortfolioInitialSelectionResolver.InitialSelection? = when (launchMode) {
AddToPortfolioManager.LaunchMode.Preselected -> null
is AddToPortfolioManager.LaunchMode.ViaUserPortfolio,
AddToPortfolioManager.LaunchMode.DirectAdd,
-> if (designFeatureToggles.isRedesignEnabled) {
getInitialSelection(initialData)
} else {
null
}
}
val firstSelectedPortfolioFlow: Flow<SelectedPortfolio> =
setupPortfolioFlow(initialData).onEach { selectedPortfolio.emit(it) }
val firstSelectedNetworkFlow: Flow<SelectedNetwork> =
setupNetworkFlow(firstSelectedPortfolioFlow).onEach { selectedNetwork.emit(it) }
// main flow that combine all require data
val allRequireForAdd = combine(
flow = firstSelectedNetworkFlow,
flow2 = firstSelectedPortfolioFlow,
transform = { a, b -> a to b },
)
var firstPartOfNavigation: Job? = null
if (initialSelection != null) {
launch {
portfolioSelectorController.selectAccount(initialSelection.account.account.accountId)
callbackDelegate.onNetworkSelected.send(initialSelection.network)
}
} else {
firstPartOfNavigation = lineNavigationFlowToAddTokenScreen(
isAccountMode = isAccountMode,
data = initialData,
firstSelectedPortfolioFlow = firstSelectedPortfolioFlow,
).launchIn(this)
}
// suspend until all required data is selected
val (firstSelectedNetwork, firstSelectedPortfolio) = allRequireForAdd.first()
// line navigation to AddToken screen is finished; cancel the job if exists
firstPartOfNavigation?.cancel()
val alreadyAddedToken = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = firstSelectedPortfolio.userWallet.walletId,
currency = firstSelectedNetwork.cryptoCurrency,
).getOrNull()
if (alreadyAddedToken != null) {
val result = AddToPortfolioManager.Result(
wallet = firstSelectedPortfolio.userWallet,
account = firstSelectedPortfolio.account.account,
addedCurrency = alreadyAddedToken.status,
)
finishOnAddedTokenClick(result)
return@channelFlow
}
val selectedNetworkName = firstSelectedNetwork.cryptoCurrency.network.name
analyticsEventHandler.send(event = eventBuilder.popupToConfirm(selectedNetworkName))
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 = if (designFeatureToggles.isRedesignEnabled) {
changePortfolioNavigationNewFlow(
data = initialData,
orderedNetworks = paramsSnapshot.networks,
tokenParams = tokenMarketParams,
)
} else {
changePortfolioNavigationFlow(initialData)
}.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()
analyticsEventHandler.send(eventBuilder.getTokenLater())
finishSuccessFlow(result)
}
.catch { throwable ->
TangemLogger.e("Error", throwable)
addToPortfolioManager.onDismiss()
}
.launchIn(modelScope)
}
private suspend fun getInitialSelection(
initialData: AvailableToAddData,
): AddToPortfolioInitialSelectionResolver.InitialSelection? {
return selectionResolver.resolve(
availableToAddData = initialData,
orderedNetworks = paramsSnapshot.networks,
selectedWallet = globalSelectedWallet,
tokenParams = paramsSnapshot.token,
)
}
private fun logAccountSelector(isAccountMode: Boolean) {
if (isAccountMode) {
analyticsEventHandler.send(eventBuilder.popupToChooseAccount())
}
}
private fun changeNetworkNavigationFlow(): Flow<SelectedNetwork> {
return setupNetworkFlow(selectedPortfolio)
.onEach { newNetwork ->
selectedNetwork.emit(newNetwork)
navigation.popToFirst()
}
}
private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow<Unit> = flow {
val selectedPortfolioValue = selectedPortfolio.first()
val selectedAccount = selectedPortfolioValue.account.account.account.accountId
portfolioSelectorController.selectAccount(selectedAccount)
val changedPortfolio = setupPortfolioFlow(data)
.drop(1)
.onEach { portfolio ->
val isSingleAvailableNetwork = portfolio.account.isSingleNetwork
if (isSingleAvailableNetwork) {
val singleNetwork = portfolio.account.availableToAddNetworks.first()
callbackDelegate.onNetworkSelected(singleNetwork)
} else {
navigation.pushNew(routeToNetworkSelector(portfolio))
}
}
val changedNetwork = setupNetworkFlow(changedPortfolio)
combine(
flow = changedPortfolio,
flow2 = changedNetwork,
transform = { newPortfolio, newNetwork ->
selectedPortfolio.tryEmit(newPortfolio)
selectedNetwork.tryEmit(newNetwork)
navigation.popToFirst()
},
).collect { emit(it) }
}
private fun changePortfolioNavigationNewFlow(
data: AvailableToAddData,
orderedNetworks: List<TokenMarketInfo.Network>,
tokenParams: RawMarketToken,
): Flow<Unit> {
return setupPortfolioFlow(data)
// drop first selected portfolio or any selected before
.drop(1)
.map { newPortfolio ->
val rebuiltSelectedNetwork = selectionResolver.resolve(
availableToAddData = data,
orderedNetworks = orderedNetworks,
selectedWallet = globalSelectedWallet,
tokenParams = tokenParams,
accountToAdd = newPortfolio.account,
preferredNetwork = selectedNetwork.first().selectedNetwork,
)?.toSelectedNetwork()
if (rebuiltSelectedNetwork != null) {
this.selectedNetwork.tryEmit(rebuiltSelectedNetwork)
}
selectedPortfolio.tryEmit(newPortfolio)
navigation.popToFirst()
}
}
private fun setupTokenActionsFlow(
selectedPortfolio: SelectedPortfolio,
addedToken: CryptoCurrencyStatus,
): Flow<CryptoCurrencyData> {
val timeFlow = channelFlow {
val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) }
getCryptoCurrencyActionsUseCase(
currency = addedToken.currency,
accountId = selectedPortfolio.account.account.account.accountId,
).onEach { state ->
val requestedQuickActions = toQuickActions(state.states, designFeatureToggles.isRedesignEnabled)
when {
requestedQuickActions.isNotEmpty() -> {
timerJob.cancel()
send(state)
}
// wait any requestedQuickActions while timer active
timerJob.isActive -> Unit
else -> close()
}
}.collect()
}
return timeFlow.map { actionsState ->
CryptoCurrencyData(
userWallet = selectedPortfolio.userWallet,
status = actionsState.cryptoCurrencyStatus,
actions = actionsState.states,
isAccountMode = selectedPortfolio.isAccountMode,
account = selectedPortfolio.account.account,
)
}
}
private fun setupPortfolioFlow(data: AvailableToAddData): Flow<SelectedPortfolio> = combine(
flow = portfolioSelectorController.isAccountMode,
flow2 = portfolioSelectorController.selectedAccount,
transform = { isAccountMode, selectedAccountId ->
selectedAccountId ?: return@combine null
val availableToAddWallets =
data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null
val availableToAddAccount =
availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null
if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged())
SelectedPortfolio(
isAccountMode = isAccountMode,
userWallet = availableToAddWallets.userWallet,
account = availableToAddAccount,
isAvailableMorePortfolio = !data.isSinglePortfolio,
)
},
)
.filterNotNull()
private fun setupNetworkFlow(selectedPortfolioFlow: Flow<SelectedPortfolio>): Flow<SelectedNetwork> = combine(
flow = selectedPortfolioFlow,
flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(),
transform = transform@{ selectedPortfolio, selectedNetwork ->
SelectedNetwork(
cryptoCurrency = createCryptoCurrency(
userWallet = selectedPortfolio.userWallet,
network = selectedNetwork,
account = selectedPortfolio.account,
) ?: return@transform null,
selectedNetwork = selectedNetwork,
isAvailableMoreNetwork = !selectedPortfolio.account.isSingleNetwork,
)
},
)
.filterNotNull()
private suspend fun createCryptoCurrency(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
account: AvailableToAddAccount,
): CryptoCurrency? {
val accountIndex = account.account.account.derivationIndex
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = addToPortfolioManager.token(),
network = network,
accountIndex = accountIndex,
)
}
private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector {
return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio)
}
private fun setupPortfolioSelector(data: AvailableToAddData, launchMode: AddToPortfolioManager.LaunchMode) {
portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus ->
when (launchMode) {
AddToPortfolioManager.LaunchMode.Preselected -> return@isEnabled true
AddToPortfolioManager.LaunchMode.DirectAdd,
is AddToPortfolioManager.LaunchMode.ViaUserPortfolio,
-> {
val availableWallet = data.availableToAddWallets[userWallet.walletId]
?: return@isEnabled false
val isAvailableAccount = availableWallet
.availableToAddAccounts[accountStatus.account.accountId]
?.isAvailableToAdd == true
return@isEnabled isAvailableAccount
}
}
}
}
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,
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)
}
override fun onLaterClick() {
onLaterClick.trySend(Unit)
}
override fun onChangeNetworkClick() {
onChangeNetworkClick.trySend(Unit)
}
override fun onChangePortfolioClick() {
onChangePortfolioClick.trySend(Unit)
}
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

@ -0,0 +1,57 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.commonfeatures.impl.R
internal data class AddToPortfolioRouteUiSpec(
val title: TextReference,
val isScrollable: Boolean,
val shouldApplyHorizontalPadding: Boolean,
val footer: AddToPortfolioFooterKind,
)
internal enum class AddToPortfolioFooterKind {
None,
Cancel,
UserPortfolioAdd,
}
internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (this) {
AddToPortfolioRoutes.AddToken -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.common_add_token),
isScrollable = true,
shouldApplyHorizontalPadding = true,
footer = AddToPortfolioFooterKind.None,
)
is AddToPortfolioRoutes.NetworkSelector -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.common_add_token),
isScrollable = true,
shouldApplyHorizontalPadding = true,
footer = AddToPortfolioFooterKind.Cancel,
)
AddToPortfolioRoutes.PortfolioSelector -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.common_add_token),
isScrollable = false,
shouldApplyHorizontalPadding = true,
footer = AddToPortfolioFooterKind.Cancel,
)
AddToPortfolioRoutes.UserPortfolio -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.markets_portfolio_block_title),
isScrollable = false,
shouldApplyHorizontalPadding = false,
footer = AddToPortfolioFooterKind.UserPortfolioAdd,
)
AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.common_get_token),
isScrollable = true,
shouldApplyHorizontalPadding = true,
footer = AddToPortfolioFooterKind.None,
)
AddToPortfolioRoutes.Empty -> AddToPortfolioRouteUiSpec(
title = TextReference.EMPTY,
isScrollable = false,
shouldApplyHorizontalPadding = false,
footer = AddToPortfolioFooterKind.None,
)
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import androidx.compose.runtime.Immutable
import com.tangem.core.decompose.navigation.Route
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import kotlinx.serialization.Serializable
@Serializable
@Immutable
internal sealed interface AddToPortfolioRoutes : Route {
@Serializable
data object Empty : AddToPortfolioRoutes
@Serializable
data object PortfolioSelector : AddToPortfolioRoutes
@Serializable
data class NetworkSelector(
val selectedPortfolio: SelectedPortfolio,
) : AddToPortfolioRoutes
@Serializable
data object AddToken : AddToPortfolioRoutes
@Serializable
data object UserPortfolio : AddToPortfolioRoutes
@Serializable
data object TokenActions : AddToPortfolioRoutes
}

View file

@ -0,0 +1,128 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.tangem.common.ui.addtoken.AddTokenUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class AddTokenModel @Inject constructor(
paramsContainer: ParamsContainer,
private val uiBuilder: AddTokenUiBuilder,
private val messageSender: UiMessageSender,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val checkCurrencyUnsupportedDelegate: CheckCurrencyUnsupportedDelegate,
) : Model() {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private val addTokenJob = JobHolder()
val uiState: StateFlow<AddTokenUM?>
field = MutableStateFlow(value = null)
init {
combine(
flow = params.selectedNetwork.distinctUntilChanged(),
flow2 = params.selectedPortfolio.distinctUntilChanged(),
transform = { selectedNetwork, selectedPortfolio ->
addTokenJob.join()
val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio)
uiBuilder.updateContent(
selectedPortfolio = selectedPortfolio,
selectedNetwork = selectedNetwork,
isTangemIconVisible = isTangemIconVisible,
onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) },
)
},
)
.onEach { newUI -> uiState.value = newUI }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onAddClick(selectedNetwork: SelectedNetwork, selectedPortfolio: SelectedPortfolio) =
modelScope.launch(dispatchers.default) {
val um = uiState.value ?: return@launch
val cryptoCurrency = selectedNetwork.cryptoCurrency
val account = selectedPortfolio.account.account.account
val accountId = account.accountId
val isMainNetwork = selectedNetwork.selectedNetwork.contractAddress == null
val unsupportedCurrency = checkCurrencyUnsupportedDelegate.checkCurrencyUnsupportedState(
userWalletId = accountId.userWalletId,
rawNetworkId = selectedNetwork.selectedNetwork.networkId,
isMainNetwork = isMainNetwork,
)
if (unsupportedCurrency != null) return@launch
uiState.value = um.toggleProgress(true)
val blockchainNames = listOf(selectedNetwork.selectedNetwork)
.mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name }
val analyticsEventBuilder = params.eventBuilder
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames))
analyticsEventHandler.send(analyticsEventBuilder.addButtonClick())
manageCryptoCurrenciesUseCase.invokeAndAwait(accountId = accountId, add = listOf(cryptoCurrency))
.onLeft { throwable ->
processError(error = throwable)
uiState.value = um.toggleProgress(false)
return@launch
}
val status = getAccountCurrencyStatusUseCase(
userWalletId = accountId.userWalletId,
currencyId = cryptoCurrency.id,
network = cryptoCurrency.network,
)
.firstOrNull()
if (status == null) {
processError(error = null)
} else {
params.callbacks.onTokenAdded(status.status)
}
uiState.value = um.toggleProgress(false)
}
private suspend fun needColdWalletInteraction(
selectedNetwork: SelectedNetwork,
selectedPortfolio: SelectedPortfolio,
): Boolean = coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = selectedPortfolio.userWallet.walletId,
networksWithDerivationPath = mapOf(
selectedNetwork.selectedNetwork.networkId to selectedNetwork.cryptoCurrency.network.derivationPath.value,
),
)
private fun processError(error: Throwable?) {
val message = error?.message?.let { stringReference(it) }
?: resourceReference(R.string.common_something_went_wrong)
messageSender.send(ToastMessage(message = message))
}
}

View file

@ -0,0 +1,107 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.PortfolioSelectUM
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.addtoken.AddTokenUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.common.ui.extensions.iconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.AccountStatus.*
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork
import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import com.tangem.features.commonfeatures.impl.R
import javax.inject.Inject
@ModelScoped
internal class AddTokenUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
) {
private val params = paramsContainer.require<AddTokenComponent.Params>()
private fun createNetwork(selectedNetwork: SelectedNetwork): AddTokenUM.Network {
return AddTokenUM.Network(
icon = selectedNetwork.cryptoCurrency.network.iconResId,
name = stringReference(selectedNetwork.cryptoCurrency.network.name),
editable = selectedNetwork.isAvailableMoreNetwork,
onClick = { params.callbacks.onChangeNetworkClick() },
)
}
private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM {
val accountIcon: AccountIconUM?
val portfolioName: TextReference
when (selectedPortfolio.isAccountMode) {
false -> {
accountIcon = null
portfolioName = stringReference(selectedPortfolio.userWallet.name)
}
true -> {
val accountStatus = selectedPortfolio.account.account
portfolioName = accountStatus.account.accountName.toUM().value
accountIcon = when (accountStatus) {
is CryptoPortfolio -> CryptoPortfolioIconConverter.convert(accountStatus.account.icon)
is Payment -> AccountIconUM.Payment
}
}
}
return PortfolioSelectUM(
icon = accountIcon,
name = portfolioName,
isAccountMode = selectedPortfolio.isAccountMode,
isMultiChoice = selectedPortfolio.isAvailableMorePortfolio,
onClick = { params.callbacks.onChangePortfolioClick() },
)
}
fun updateContent(
selectedPortfolio: SelectedPortfolio,
selectedNetwork: SelectedNetwork,
isTangemIconVisible: Boolean,
onConfirmClick: () -> Unit,
): AddTokenUM {
// its may happens when change portfolio after selected both params in line navigation
val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks
.any { selectedNetwork.selectedNetwork.networkId == it.networkId }
val button = AddTokenUM.Button(
isEnabled = isAvailableNetwork,
showProgress = false,
isTangemIconVisible = isTangemIconVisible,
text = resourceReference(R.string.common_add),
onConfirmClick = onConfirmClick,
)
val networkUM = createNetwork(selectedNetwork)
val portfolioUM = createPortfolio(selectedPortfolio)
val currency = selectedNetwork.cryptoCurrency
val tokenToAdd = TokenItemState.Content(
id = currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(currency),
titleState = TokenItemState.TitleState.Content(stringReference(currency.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""),
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return AddTokenUM(
tokenToAdd = tokenToAdd,
network = networkUM,
portfolio = portfolioUM,
button = button,
)
}
companion object {
fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy(
button = this.button.copy(showProgress = showProgress),
)
}
}

View file

@ -0,0 +1,77 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import arrow.core.getOrElse
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.impl.R
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
class CheckCurrencyUnsupportedDelegate @Inject constructor(
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val messageSender: UiMessageSender,
) {
suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
val result = checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse { throwable ->
TangemLogger.e(
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
throwable,
)
val message = SnackbarMessage(
message = throwable.localizedMessage?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
if (result != null) {
showUnsupportedWarning(result)
}
return result
}
private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) {
val message = DialogMessage(
message = when (unsupportedState) {
is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference(
id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
)
messageSender.send(message)
}
}

View file

@ -0,0 +1,60 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.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.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.BlockchainRowUMConverter
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class ChooseNetworkModel @Inject constructor(
paramsContainer: ParamsContainer,
private val checkCurrencyUnsupportedDelegate: CheckCurrencyUnsupportedDelegate,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<ChooseNetworkComponent.Params>()
val uiState: StateFlow<ChooseNetworkUM> = MutableStateFlow(buildUI())
private fun buildUI(): ChooseNetworkUM {
val allAvailable = params.selectedPortfolio.account.availableNetworks
val alreadyAdded = allAvailable
.subtract(params.selectedPortfolio.account.availableToAddNetworks)
val converter = BlockchainRowUMConverter(
alreadyAddedNetworks = alreadyAdded.mapTo(mutableSetOf()) { it.networkId },
)
val allAvailableNetworks = allAvailable.map { it to true }
return ChooseNetworkUM(
networks = converter.convertList(allAvailableNetworks).toPersistentList(),
onNetworkClick = onNetworkClick@{ row ->
val network = allAvailable
.find { it.networkId == row.id }
?: return@onNetworkClick
checkNetwork(row, network)
},
)
}
private fun checkNetwork(row: BlockchainRowUM, network: TokenMarketInfo.Network) = modelScope.launch {
val selectedWalletId = params.selectedPortfolio.userWallet.walletId
val unsupportedState = checkCurrencyUnsupportedDelegate.checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = row.id,
isMainNetwork = row.isMainNetwork,
)
if (unsupportedState == null) {
params.callbacks.onNetworkSelected(network)
}
}
}

View file

@ -0,0 +1,90 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class TokenActionsModel @Inject constructor(
paramsContainer: ParamsContainer,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
tokenActionsIntentsFactory: TokenActionsHandler.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val uiBuilder: TokenActionsUiBuilder,
private val analyticsEventHandler: AnalyticsEventHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
) : Model() {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
private val analyticsEventBuilder get() = params.eventBuilder
private val currentAppCurrency = getSelectedAppCurrencyUseCase.invokeOrDefault()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val tokenActionsHandler: TokenActionsHandler =
tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) },
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
@OptIn(ExperimentalCoroutinesApi::class)
val uiState: StateFlow<TokenActionsUM?> =
combine(
params.data,
getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { cryptoCurrencyData, isBalanceHidden ->
cryptoCurrencyData to isBalanceHidden
}
.mapLatest { (cryptoCurrencyData, isBalanceHidden) ->
uiBuilder.build(
cryptoCurrencyData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
appCurrency = currentAppCurrency.value,
isBalanceHidden = isBalanceHidden,
)
}
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch {
val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action)
analyticsEventHandler.send(event)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return@launch
modelScope.launch {
val tokenConfig = receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
) ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
}
}

View file

@ -0,0 +1,217 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getTotalCryptoAmount
import com.tangem.common.getTotalFiatAmount
import com.tangem.common.ui.account.*
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions
import com.tangem.common.ui.markets.action.TokenActionsHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.R
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition
import com.tangem.core.ui.ds.badge.TangemBadgeShape
import com.tangem.core.ui.ds.badge.TangemBadgeSize
import com.tangem.core.ui.ds.badge.TangemBadgeUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import java.math.BigDecimal
import javax.inject.Inject
@ModelScoped
internal class TokenActionsUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
private val designFeatureToggles: DesignFeatureToggles,
) {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
fun build(
cryptoCurrencyData: CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
appCurrency: AppCurrency,
isBalanceHidden: Boolean,
): TokenActionsUM {
return if (designFeatureToggles.isRedesignEnabled) {
buildV2(
cryptoCurrencyData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
)
} else {
buildV1(
cryptoCurrencyData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
)
}
}
private fun buildV1(
cryptoCurrencyData: CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
): TokenActionsUM {
val status = cryptoCurrencyData.status
val tokenUM = TokenItemState.Content(
id = status.currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(status.currency),
titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)),
fiatAmountState = null,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return TokenActionsUM(
token = tokenUM,
quickActions = quickActions(
cryptoData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
isRedesignEnabled = false,
),
onLaterClick = {
params.callbacks.onLaterClick()
},
)
}
private fun buildV2(
cryptoCurrencyData: CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
appCurrency: AppCurrency,
isBalanceHidden: Boolean,
): TokenActionsUM {
val status = cryptoCurrencyData.status
val tokenUM = TokenItemState.Content(
id = status.currency.id.value,
iconState = CryptoCurrencyToIconStateConverter().convert(status.currency),
titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)),
fiatAmountState = createFiatAmountState(status, appCurrency),
subtitle2State = createSubtitle2State(status),
subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)),
onItemClick = null,
onItemLongClick = null,
)
return TokenActionsUM(
token = tokenUM,
quickActions = quickActions(
cryptoData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
isRedesignEnabled = true,
),
onLaterClick = {
params.callbacks.onLaterClick()
},
isBalancesHidden = isBalanceHidden,
portfolioBadge = createPortfolioBadge(cryptoCurrencyData),
)
}
private fun createPortfolioBadge(cryptoCurrencyData: CryptoCurrencyData): TangemBadgeUM {
val icon: AccountIconUM?
val name = if (cryptoCurrencyData.isAccountMode) {
icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon)
cryptoCurrencyData
.account
.account
.accountName
.toUM()
.value
} else {
icon = null
stringReference(cryptoCurrencyData.userWallet.name)
}
return TangemBadgeUM(
text = name,
tangemIconUM = if (icon == null) {
TangemIconUM.Icon(
iconRes = R.drawable.ic_key_card_20,
tintReference = { TangemTheme.colors2.graphic.neutral.tertiary },
)
} else {
TangemIconUM.Icon(
iconRes = icon.value.getResId(),
tintReference = { icon.color.getUiColor() },
)
},
size = TangemBadgeSize.X6,
shape = TangemBadgeShape.Rounded,
iconPosition = if (cryptoCurrencyData.isAccountMode) {
TangemBadgeIconPosition.Start
} else {
TangemBadgeIconPosition.End
},
shouldRespectIconTint = cryptoCurrencyData.isAccountMode,
)
}
private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? {
return when (status.value) {
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> {
TokenItemState.Subtitle2State.TextContent(
text = status.getTotalCryptoAmount().format {
crypto(status.currency)
},
)
}
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> TokenItemState.Subtitle2State.TextContent(
text = BigDecimal.ZERO.format {
crypto(status.currency)
},
)
}
}
private fun createFiatAmountState(
status: CryptoCurrencyStatus,
appCurrency: AppCurrency,
): TokenItemState.FiatAmountState? {
return when (status.value) {
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> {
TokenItemState.FiatAmountState.AnnotatedContent(
text = status.getTotalFiatAmount().formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
).price()
},
)
}
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Loading,
-> TokenItemState.FiatAmountState.AnnotatedContent(
text = BigDecimal.ZERO.formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
).price()
},
)
}
}
}

View file

@ -0,0 +1,268 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.ds.badge.TangemBadge
import com.tangem.core.ui.ds.badge.TangemBadgeShape
import com.tangem.core.ui.ds.badge.TangemBadgeSize
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
private const val DISABLED_ALPHA = 0.4f
@Composable
internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) {
if (LocalRedesignEnabled.current) {
ChooseNetworkContentV2(
state = state,
modifier = modifier,
)
} else {
ChooseNetworkContentV1(
state = state,
modifier = modifier,
)
}
}
@Composable
internal fun ChooseNetworkContentV1(state: ChooseNetworkUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.action),
) {
state.networks.fastForEach { model ->
key(model.id) {
BlockchainRow(
model = model,
itemPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing14,
),
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }),
) {
if (!model.isEnabled) {
Label(
modifier = Modifier.alpha(DISABLED_ALPHA),
state = LabelUM(
text = resourceReference(R.string.common_added),
style = LabelStyle.REGULAR,
),
)
}
}
}
}
}
}
@Composable
internal fun ChooseNetworkContentV2(state: ChooseNetworkUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(34.dp),
)
.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2),
) {
Text(
modifier = Modifier.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2),
text = stringResourceSafe(R.string.common_choose_network),
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.tertiary,
)
state.networks.fastForEach { model ->
key(model.id) {
TangemRowContainer(
modifier = Modifier.clickable(
enabled = model.isEnabled,
onClick = { state.onNetworkClick(model) },
),
contentPadding = PaddingValues(vertical = TangemTheme.dimens2.x3),
content = {
NetworkIcon(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3),
model = model,
)
NetworkText(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP),
model = model,
)
if (!model.isEnabled) {
TangemBadge(
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.TAIL)
.padding(start = TangemTheme.dimens2.x2),
text = resourceReference(R.string.common_added),
size = TangemBadgeSize.X6,
shape = TangemBadgeShape.Rounded,
)
}
},
)
}
}
}
}
@Composable
private fun NetworkIcon(model: BlockchainRowUM, modifier: Modifier = Modifier) {
if (model.isSelected && model.isEnabled) {
Image(
modifier = modifier
.size(TangemTheme.dimens2.x10),
painter = painterResource(id = model.iconResId),
contentDescription = null,
)
} else {
Icon(
modifier = modifier
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.size(TangemTheme.dimens2.x10),
painter = painterResource(id = model.iconResId),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
)
}
}
@Composable
private fun NetworkText(model: BlockchainRowUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Text(
modifier = Modifier.weight(weight = 10f, fill = false),
text = model.name,
style = TangemTheme.typography2.bodyMedium16,
color = when {
model.isEnabled && model.isMainNetwork -> TangemTheme.colors2.text.neutral.primary
model.isEnabled -> TangemTheme.colors2.text.neutral.secondary
else -> TangemTheme.colors2.text.status.disabled
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
modifier = Modifier.weight(weight = 5f, fill = false),
text = model.type,
style = TangemTheme.typography2.captionMedium12,
color = if (model.isEnabled) {
TangemTheme.colors2.text.neutral.tertiary
} else {
TangemTheme.colors2.text.status.disabled
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) {
TangemThemePreview {
ChooseNetworkContent(
state = content,
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun PreviewV2(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
ChooseNetworkContent(
state = content,
)
}
}
}
internal class ChooseNetworkContentProvider : PreviewParameterProvider<ChooseNetworkUM> {
private val blockchainRow = BlockchainRowUM(
id = UUID.randomUUID().toString(),
name = "Etherium 3",
type = "TEST",
iconResId = R.drawable.img_eth_22,
isMainNetwork = false,
isSelected = true,
isEnabled = true,
)
override val values: Sequence<ChooseNetworkUM>
get() = sequenceOf(
ChooseNetworkUM(
onNetworkClick = {},
networks = persistentListOf(
blockchainRow.copy(
type = "MAIN",
isMainNetwork = true,
),
blockchainRow.copy(
iconResId = R.drawable.ic_bsc_16,
isEnabled = false,
),
blockchainRow.copy(iconResId = R.drawable.img_polygon_22),
blockchainRow.copy(iconResId = R.drawable.img_optimism_22),
),
),
)
}

View file

@ -0,0 +1,125 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
import com.tangem.domain.markets.RawMarketToken
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.*
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.AvailableToAddDataConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
internal class DefaultAddToPortfolioManager @AssistedInject constructor(
private val availableToAddDataConverter: AvailableToAddDataConverter,
@Assisted override val settings: Settings,
@Assisted override val analyticsParams: AnalyticsParams,
@Assisted val scope: CoroutineScope,
dispatchers: CoroutineDispatcherProvider,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : AddToPortfolioManager {
override val onDismiss: Channel<Unit> = Channel()
override val onSuccessAdded: Channel<Result> = Channel()
override val onAddedTokenClick: Channel<Result> = Channel()
override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true),
scope = scope,
)
private val internalParamsFlow = MutableStateFlow(ParamsInternal())
override val paramsFlow = internalParamsFlow
.transform { internalParams ->
val fullParams = Params(
networks = internalParams.networks ?: return@transform,
token = internalParams.token ?: return@transform,
launchMode = internalParams.launchMode,
)
emit(fullParams)
}
.distinctUntilChanged()
.shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1)
override val state: MutableStateFlow<State> = MutableStateFlow(State.Loading)
init {
buildFlow()
.onEach { newState -> state.update { newState } }
.flowOn(dispatchers.default)
.launchIn(scope)
}
override fun onDismiss() {
onDismiss.trySend(Unit)
}
override fun onSuccessAdded(result: Result) {
onSuccessAdded.trySend(result)
}
override fun onAddedTokenClick(result: Result) {
onAddedTokenClick.trySend(result)
}
override fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
updateInternal(networks = networks)
}
override fun setTokenParams(token: RawMarketToken) {
updateInternal(token = token)
}
override fun updateLaunchMode(launchMode: LaunchMode) {
updateInternal(launchMode = launchMode)
}
private fun updateInternal(
networks: List<TokenMarketInfo.Network>? = null,
token: RawMarketToken? = null,
launchMode: LaunchMode? = null,
) {
internalParamsFlow.update { prev ->
val newParams = ParamsInternal(
networks = networks ?: prev.networks,
token = token ?: prev.token,
launchMode = launchMode ?: prev.launchMode,
)
val shouldReload = newParams != prev
if (shouldReload) state.update { State.Loading }
return@update newParams
}
}
private fun buildFlow(): Flow<State> = combine(
flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(),
flow2 = paramsFlow,
) { balances, (availableNetworks, token) ->
val data = availableToAddDataConverter.convert(
balances = balances,
availableNetworks = availableNetworks.toSet(),
marketParams = token,
)
State.Ready(data)
}
@AssistedFactory
interface Factory : AddToPortfolioManager.Factory {
override fun create(
scope: CoroutineScope,
settings: Settings,
analyticsParams: AnalyticsParams,
): DefaultAddToPortfolioManager
}
private data class ParamsInternal(
val launchMode: LaunchMode = LaunchMode.DirectAdd,
val networks: List<TokenMarketInfo.Network>? = null,
val token: RawMarketToken? = null,
)
}

View file

@ -0,0 +1,204 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.action.QuickActionUM
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.badge.drawBadge
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@Composable
internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
) {
TokenItem(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action),
state = state.token,
isBalanceHidden = false,
)
SpacerH(TangemTheme.dimens.spacing14)
Column(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(TangemTheme.colors.background.action),
) {
state.quickActions.actions.fastForEach { actionUM ->
key(actionUM.title) {
ActionRow(
state = actionUM,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) },
)
}
}
}
SpacerH16()
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.common_later),
onClick = state.onLaterClick,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ActionRow(
state: QuickActionUM,
onClick: () -> Unit,
onLongClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
val onLongClickInternal = {
hapticManager.perform(TangemHapticEffect.View.LongPress)
onLongClick()
}
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable },
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onClick()
},
)
.padding(horizontal = TangemTheme.dimens.spacing12, vertical = TangemTheme.dimens.spacing15),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
val containerColor = TangemTheme.colors.background.action
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = CircleShape,
)
.size(36.dp)
.drawWithContent {
drawContent()
if (state is QuickActionUM.V1.Exchange && state.shouldShowBadge) {
drawBadge(containerColor = containerColor, offset = 4.dp)
}
},
) {
Icon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size16),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(TokenActionsContentPreviewProvider::class) state: TokenActionsUM) {
TangemThemePreview {
TokenActionsContent(
state = state,
)
}
}
private class TokenActionsContentPreviewProvider : PreviewParameterProvider<TokenActionsUM> {
private val tokenState
get() = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(
text = stringReference(value = "Tether"),
),
fiatAmountState = null,
subtitle2State = null,
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")),
onItemClick = {},
onItemLongClick = {},
)
override val values: Sequence<TokenActionsUM>
get() = sequenceOf(
TokenActionsUM(
quickActions = QuickActions(
actions = persistentListOf(
QuickActionUM.V1.Buy,
QuickActionUM.V1.Exchange(shouldShowBadge = true),
QuickActionUM.V1.Receive,
),
onQuickActionClick = {},
onQuickActionLongClick = {},
),
token = tokenState,
onLaterClick = {},
),
)
}

View file

@ -0,0 +1,295 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.action.QuickActionUM
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.ds.badge.*
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
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.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.*
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
import java.util.UUID
private const val ACTION_BACKGROUND_ALPHA = .1f
@Composable
internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
) {
TokenHeader(
addedToken = state.token,
portfolioBadge = state.portfolioBadge,
isBalanceHidden = state.isBalancesHidden,
)
SpacerH(TangemTheme.dimens2.x2)
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
state.quickActions.actions.fastForEach { actionUM ->
key(actionUM.title) {
ActionRow(
state = actionUM,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) },
)
}
}
}
SpacerH(TangemTheme.dimens2.x2)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = state.onLaterClick,
text = resourceReference(R.string.common_later),
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ActionRow(
state: QuickActionUM,
onClick: () -> Unit,
onLongClick: (() -> Unit),
modifier: Modifier = Modifier,
) {
val hapticManager = LocalHapticManager.current
val onLongClickInternal = {
hapticManager.perform(TangemHapticEffect.View.LongPress)
onLongClick()
}
TangemRowContainer(
modifier = modifier
.combinedClickable(
onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable },
onClick = {
hapticManager.perform(TangemHapticEffect.View.SegmentTick)
onClick()
},
)
.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
),
) {
Box(
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(40.dp)
.background(
color = TangemTheme.colors2.graphic.status.accent.copy(alpha = ACTION_BACKGROUND_ALPHA),
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(id = state.icon),
contentDescription = null,
tint = TangemTheme.colors2.graphic.status.accent,
)
}
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = state.title.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = state.description.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
)
Icon(
modifier = Modifier
.layoutId(TangemRowLayoutId.TAIL)
.padding(start = TangemTheme.dimens2.x2)
.size(24.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
)
}
}
@Composable
private fun TokenHeader(
addedToken: TokenItemState,
isBalanceHidden: Boolean,
portfolioBadge: TangemBadgeUM?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x8),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CurrencyIcon(
state = addedToken.iconState,
iconSize = 70.dp,
networkBadgeSize = 24.dp,
)
SpacerH(TangemTheme.dimens2.x5)
when (val fiat = addedToken.fiatAmountState) {
is TokenItemState.FiatAmountState.AnnotatedContent -> {
Text(
text = fiat.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
)
SpacerH(TangemTheme.dimens2.x2)
}
else -> Unit
}
when (val cryptoAmount = addedToken.subtitle2State) {
is TokenItemState.Subtitle2State.TextContent -> {
Text(
text = cryptoAmount.text.orMaskWithStars(isBalanceHidden),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.secondary,
)
SpacerH(TangemTheme.dimens2.x2)
}
else -> Unit
}
SpacerH(TangemTheme.dimens2.x7)
if (portfolioBadge == null) return
TangemBadge(portfolioBadge)
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(TokenActionsContentPreviewProviderV2::class) state: TokenActionsUM) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors2.surface.level2)
.padding(horizontal = 16.dp),
) {
TokenActionsContentV2(
state = state,
)
}
}
}
}
private class TokenActionsContentPreviewProviderV2 : PreviewParameterProvider<TokenActionsUM> {
private val tokenState
get() = TokenItemState.Content(
id = UUID.randomUUID().toString(),
iconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(
text = stringReference(value = "Tether"),
),
fiatAmountState = TokenItemState.FiatAmountState.AnnotatedContent(
text = BigDecimal.ONE.formatStyled {
fiat(
fiatCurrencyCode = "USD",
fiatCurrencySymbol = "$",
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
).price()
},
),
subtitle2State = TokenItemState.Subtitle2State.TextContent(
"1.01 USDT",
),
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")),
onItemClick = {},
onItemLongClick = {},
)
override val values: Sequence<TokenActionsUM>
get() = sequenceOf(
TokenActionsUM(
quickActions = QuickActions(
actions = persistentListOf(
QuickActionUM.V2.Buy,
QuickActionUM.V2.Exchange(shouldShowBadge = true),
QuickActionUM.V2.Receive,
),
onQuickActionClick = {},
onQuickActionLongClick = {},
),
token = tokenState,
onLaterClick = {},
portfolioBadge = TangemBadgeUM(
text = stringReference("Wallet 2"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_key_card_20,
tintReference = { TangemTheme.colors2.graphic.neutral.tertiary },
),
size = TangemBadgeSize.X6,
shape = TangemBadgeShape.Rounded,
iconPosition = TangemBadgeIconPosition.End,
),
),
)
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import kotlinx.collections.immutable.ImmutableList
data class ChooseNetworkUM(
val networks: ImmutableList<BlockchainRowUM>,
val onNetworkClick: (BlockchainRowUM) -> Unit,
)

View file

@ -0,0 +1,13 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state
import com.tangem.common.ui.markets.action.QuickActions
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.ds.badge.TangemBadgeUM
internal data class TokenActionsUM(
val token: TokenItemState,
val quickActions: QuickActions,
val onLaterClick: () -> Unit,
val isBalancesHidden: Boolean = false,
val portfolioBadge: TangemBadgeUM? = null,
)

View file

@ -0,0 +1,40 @@
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.TokenSelectorEmbeddedContent
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
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)
@Composable
override fun Content(modifier: Modifier) {
val stateFlow = model.state.collectAsStateWithLifecycle()
val state = stateFlow.value ?: return
TokenSelectorEmbeddedContent(
content = state.content,
modifier = modifier,
scrollBottomInset = LocalTangemBottomSheetContentBottomInset.current,
)
}
@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,88 @@
package com.tangem.features.commonfeatures.impl.choosetoken
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge.Settings
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioFullBlockDelegate
import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioListBlockDelegate
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
internal class DefaultChooseTokenBridge @AssistedInject constructor(
@Assisted private val modelScope: CoroutineScope,
portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory,
portfolioFullBlockDelegateFactory: PortfolioFullBlockDelegate.Factory,
@Assisted override val settings: Settings,
@Assisted override val analyticsPayload: Set<ChooseTokenAnalyticsPayload>,
) : ChooseTokenBridge {
override val onCurrencyChosen: Channel<ChooseTokenResult> = Channel()
override val onClose: Channel<Unit> = Channel()
private val onSearchQuery: Channel<SearchQuery> = Channel()
override val searchQueryState: StateFlow<SearchQuery> = onSearchQuery.receiveAsFlow()
.debounce(ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = SearchQuery.Empty)
private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
featureSettings = settings,
)
private val portfolioFullBlockDelegate: PortfolioFullBlockDelegate = portfolioFullBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
portfolioListBlockDelegate = portfolioListBlockDelegate,
)
override val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>
get() = portfolioListBlockDelegate.tokenFilter
override val fullPortfolioBlock: StateFlow<ChooseTokenPortfolioFullBlockUM?>
get() = portfolioFullBlockDelegate.fullPortfolioBlock
init {
portfolioListBlockDelegate.onTokenChosen.receiveAsFlow()
.onEach { chooseResult -> onCurrencyChosen(chooseResult) }
.launchIn(modelScope)
}
override fun selectWalletTab(walletId: UserWalletId) {
portfolioFullBlockDelegate.selectWalletTab(walletId)
}
override fun onSearchQuery(query: SearchQuery) {
onSearchQuery.trySend(query)
}
override fun onCurrencyChosen(result: ChooseTokenResult) {
onCurrencyChosen.trySend(result)
onSearchQuery(SearchQuery.Empty)
}
override fun onClose() {
onClose.trySend(Unit)
onSearchQuery(SearchQuery.Empty)
}
@AssistedFactory
interface Factory : ChooseTokenBridge.Factory {
override fun create(
modelScope: CoroutineScope,
settings: Settings,
analyticsPayload: Set<ChooseTokenAnalyticsPayload>,
): DefaultChooseTokenBridge
}
}

View file

@ -0,0 +1,72 @@
package com.tangem.features.commonfeatures.impl.choosetoken
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenScreen
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.serialization.Serializable
internal class DefaultChooseTokenComponent @AssistedInject constructor(
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: ChooseTokenComponent.Params,
) : AppComponentContext by appComponentContext, ChooseTokenComponent {
private val model: ChooseTokenModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = AddToPortfolioRoute.serializer(),
key = BOTTOM_SHEET_SLOT_KEY,
handleBackButton = false,
childFactory = { _, context -> bottomSheetChild(context) },
)
@Composable
override fun Content(modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by model.state.collectAsStateWithLifecycle()
ChooseTokenScreen(state = state)
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnsafeCallOnNullableType")
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
return addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.addToPortfolioManager,
),
)
}
@AssistedFactory
interface Factory : ChooseTokenComponent.Factory {
override fun create(
context: AppComponentContext,
params: ChooseTokenComponent.Params,
): DefaultChooseTokenComponent
}
private companion object {
const val BOTTOM_SHEET_SLOT_KEY = "choosePortfolioTokenBottomSheetSlot"
}
}
@Serializable
internal data object AddToPortfolioRoute : Route

View file

@ -0,0 +1,39 @@
package com.tangem.features.commonfeatures.impl.choosetoken
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class SettingContextUseCase @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
) {
suspend fun invokeSync(): SettingContext = invoke().first()
operator fun invoke(): Flow<SettingContext> = combine(
flow = isAccountsModeEnabledUseCase.invoke(),
flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
transform = { isAccountsModeEnabled, selectedAppCurrency, balanceHidingSettings ->
SettingContext(
isAccountsMode = isAccountsModeEnabled,
appCurrency = selectedAppCurrency,
isBalanceHidden = balanceHidingSettings,
)
},
).distinctUntilChanged()
}
data class SettingContext(
val isAccountsMode: Boolean,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
)

View file

@ -0,0 +1,231 @@
package com.tangem.features.commonfeatures.impl.choosetoken.converter
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.account.TokensListPortfolioItemConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems
import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.IconTint
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.model.ClickIntents
import kotlinx.collections.immutable.toPersistentList
internal class ChooseTokenListItemConverter(
private val appCurrency: AppCurrency,
private val params: TokenConverterParams,
private val clickIntents: ClickIntents,
private val searchQuery: SearchQuery,
private val tokenFilter: (AccountStatus, CryptoCurrencyStatus) -> Boolean,
private val isShowPaymentAccount: Boolean,
) {
private val isSearchingState: Boolean get() = searchQuery.isSearchingState
private val onTokenClick: (account: AccountStatus, currencyStatus: CryptoCurrencyStatus) -> Unit =
{ account, currencyStatus ->
clickIntents.onTokenItemClick(account, currencyStatus)
}
private val onAccountItemClick: (Account, isExpanded: Boolean) -> Unit = { clickedAccount, isExpanded ->
if (isExpanded) {
clickIntents.onAccountCollapseClick(clickedAccount)
} else {
clickIntents.onAccountExpandClick(clickedAccount)
}
}
private val fiatAmountStateProvider: ((TotalFiatBalance, isExpanded: Boolean) -> FiatAmountState?) =
{ totalBalance, isExpanded ->
when {
isSearchingState -> FiatAmountState.Empty
!isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative)
else -> AccountCryptoPortfolioItemStateConverter
.createFiatAmountState(totalBalance, appCurrency)
}
}
private fun tokenStatusConverter(account: AccountStatus) = TokenItemStateConverter(
appCurrency = appCurrency,
onItemClick = { _, status -> onTokenClick(account, status) },
)
fun convert(): TokenListUMData {
return when (params) {
is TokenConverterParams.Account -> convertAccountList(params)
is TokenConverterParams.Wallet -> convertTokenList(
account = params.mainAccount,
tokenConverter = tokenStatusConverter(params.mainAccount),
tokenListParam = params.tokenList,
)
}
}
private fun convertAccountList(params: TokenConverterParams.Account): TokenListUMData {
val accountList = params.accountList
val accountItems = accountList.accountStatuses
.mapNotNull { accountStatus ->
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.toPortfolioItem(params)
is AccountStatus.Payment -> accountStatus.createPaymentAccountItem(params.expandedAccounts)
}
}
.filter { portfolio -> portfolio.tokens.isNotEmpty() }
if (accountItems.isEmpty()) {
return TokenListUMData.EmptyList
}
return TokenListUMData.AccountList(
tokensList = accountItems.toPersistentList(),
totalTokensCount = accountItems.sumOf { portfolio -> portfolio.tokensItemsList.size },
)
}
private fun AccountStatus.CryptoPortfolio.toPortfolioItem(
params: TokenConverterParams.Account,
): TokensListItemUM.Portfolio {
val tokenList: TokenList = this.tokenList
val account: Account.CryptoPortfolio = this.account
val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId)
val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount ->
onAccountItemClick(clickedAccount, isExpanded)
}
val converter = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = account,
onItemClick = onItemClick.takeIf { !isSearchingState },
priceChangeLce = this.priceChangeLce,
fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) },
subtitle2StateProvider = { _ -> null },
)
val accountItem = converter.convert(tokenList.totalFiatBalance)
val tokenConverter = tokenStatusConverter(this)
val tokensListState = convertTokenList(tokenConverter, tokenList, this)
val items = tokensListState.tokensList
return TokensListPortfolioItemConverter(
tokenItemUM = accountItem,
isExpanded = isExpanded,
isCollapsable = !isSearchingState,
tokens = items.filterIsInstance<PortfolioTokensListItemUM>().toPersistentList(),
).convert(Unit)
}
private fun convertTokenList(
tokenConverter: TokenItemStateConverter,
tokenListParam: TokenList,
account: AccountStatus.CryptoPortfolio,
): TokenListUMData {
return when (val tokenList = filterTokenList(tokenListParam, account)) {
is TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
is TokenList.Ungrouped -> TokenListUMData.TokenList(
tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
}
}
private fun List<CryptoCurrencyStatus>.filterCurrencies(account: AccountStatus): List<CryptoCurrencyStatus> =
filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) }
private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList {
return when (tokenList) {
TokenList.Empty -> TokenList.Empty
is TokenList.Ungrouped -> {
val filtered = tokenList.currencies.filterCurrencies(account)
if (filtered.isEmpty()) TokenList.Empty else tokenList.copy(currencies = filtered)
}
is TokenList.GroupedByNetwork -> {
val filteredGroups = tokenList.groups
.map { group ->
val filteredCurrencies = group.currencies.filterCurrencies(account)
group.copy(currencies = filteredCurrencies)
}
.filter { group -> group.currencies.isNotEmpty() }
if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups)
}
}
}
private fun CryptoCurrencyStatus.filterByQuery(): Boolean {
if (!isSearchingState) return true
val isSearchFilter = currency.name.contains(searchQuery.value, ignoreCase = true) ||
currency.symbol.contains(searchQuery.value, ignoreCase = true)
return isSearchFilter
}
private fun AccountStatus.Payment.createPaymentAccountItem(
expandedAccounts: Set<AccountId>,
): TokensListItemUM.Portfolio? {
if (!isShowPaymentAccount) return null
val paymentCurrency: CryptoCurrencyStatus = when (val status = this.value) {
is PaymentAccountStatusValue.Error,
is PaymentAccountStatusValue.IssuingCard,
PaymentAccountStatusValue.NotCreated,
is PaymentAccountStatusValue.UnderReview,
PaymentAccountStatusValue.Loading,
PaymentAccountStatusValue.Empty,
is PaymentAccountStatusValue.Deactivated,
-> return null
is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus
}
val account = this.account
val tokensCount = 1
val isExpanded = isSearchingState || expandedAccounts.contains(account.accountId)
val onItemClick: (TokenItemState) -> Unit = {
onAccountItemClick(account, isExpanded)
}
val fiatBalance: TotalFiatBalance = this.value.totalFiatBalance
val fiatAmountState = fiatAmountStateProvider(fiatBalance, isExpanded)
val paymentAccountItem = TokenItemState.Content(
id = account.accountId.value,
iconState = CurrencyIconState.PaymentAccount(),
titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = pluralReference(
R.plurals.common_tokens_count,
count = tokensCount,
formatArgs = wrappedList(tokensCount),
),
isAvailable = false,
),
onItemClick = onItemClick.takeIf { !isSearchingState },
fiatAmountState = fiatAmountState,
subtitle2State = null,
onItemLongClick = null,
)
val tokenConverter = tokenStatusConverter(this)
val filtered = listOf(paymentCurrency)
.filterCurrencies(this)
.map { currency -> TokensListItemUM.Token(tokenConverter.convert(currency)) }
return TokensListPortfolioItemConverter(
tokenItemUM = paymentAccountItem,
isExpanded = isExpanded,
isCollapsable = !isSearchingState,
tokens = filtered.toPersistentList(),
).convert(Unit)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.commonfeatures.impl.choosetoken.converter
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.utils.transformer.Transformer
internal class SearchBarToggleTransformer(private val isActive: Boolean) : Transformer<ChooseTokenInitialUM> {
override fun transform(prevState: ChooseTokenInitialUM): ChooseTokenInitialUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(isActive = isActive),
)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.commonfeatures.impl.choosetoken.converter
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.utils.transformer.Transformer
internal class SearchBarUpdateQueryTransformer(private val newQuery: String) : Transformer<ChooseTokenInitialUM> {
override fun transform(prevState: ChooseTokenInitialUM): ChooseTokenInitialUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(query = newQuery),
)
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.commonfeatures.impl.choosetoken.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.impl.choosetoken.DefaultChooseTokenBridge
import com.tangem.features.commonfeatures.impl.choosetoken.DefaultChooseTokenComponent
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(ModelComponent::class)
internal interface ChooseTokenModelModule {
@Binds
@IntoMap
@ClassKey(ChooseTokenModel::class)
fun provideChooseTokenModel(model: ChooseTokenModel): Model
}
@Module
@InstallIn(SingletonComponent::class)
internal interface ChooseTokenFeatureModule {
@Binds
@Singleton
fun provideChooseTokenComponentFactory(impl: DefaultChooseTokenComponent.Factory): ChooseTokenComponent.Factory
@Binds
@Singleton
fun provideDefaultChooseTokenBridgeFactory(impl: DefaultChooseTokenBridge.Factory): ChooseTokenBridge.Factory
}

View file

@ -0,0 +1,270 @@
package com.tangem.features.commonfeatures.impl.choosetoken.market
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.commonfeatures.impl.choosetoken.market.converter.SwapMarketsTokenItemConverter
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class MarketsListBatchFlowManager @AssistedInject constructor(
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
@Assisted private val order: TokenMarketListConfig.Order,
@Assisted private val currentSearchText: Provider<String?>,
@Assisted private val modelScope: CoroutineScope,
) {
private val actionsFlow =
MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>(replay = 1)
private val updateStateJob = JobHolder()
private val batchFlow = getMarketsTokenListFlowUseCase(
batchingContext = TokenListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
batchFlowType = batchFlowType,
)
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = resultBatches.map { it.uiBatches }
private val appCurrency: StateFlow<AppCurrency> = getSelectedAppCurrencyUseCase.invokeOrDefault()
.stateIn(modelScope, SharingStarted.Eagerly, AppCurrency.Default)
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
get() = uiBatches
.map { batches ->
batches.asSequence()
.map { it.data }
.flatten()
.toImmutableList()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = persistentListOf(),
)
val isInInitialLoadingErrorState = batchFlow.state
.map { it.status is PaginationStatus.InitialLoadingError }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
val isSearchNotFoundState = batchFlow.state
.map { batchListState ->
currentSearchText().isNullOrEmpty().not() &&
batchListState.status is PaginationStatus.EndOfPagination &&
batchListState.data.isEmpty()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
val totalCount: StateFlow<Int?> = batchFlow.state
.map { it.totalCount }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size &&
a.map { it.key } == b.map { it.key } &&
a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEach {
coroutineScope {
launch {
updateState(it)
}.saveIn(updateStateJob)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
withContext(dispatchers.default) {
resultBatches.update { resultBatches ->
val items = resultBatches.uiBatches
val previousList = resultBatches.processedItems
val converter = SwapMarketsTokenItemConverter(appCurrency = appCurrency.value)
if (newList.isEmpty()) {
return@update ResultBatches(processedItems = emptyList())
}
val isInitialLoading =
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
val outItems = if (isInitialLoading) {
newList.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
if (previousList.size != newList.size) {
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = newList.filter { keysToAdd.contains(it.key) }
items + newBatches.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (prevBatch == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data.getOrNull(index)
val newItem = newBatch.data.getOrNull(index)
if (prevItem != null && newItem != null) {
converter.update(prevItem, marketsListItemUM, newItem)
} else {
newItem?.let { converter.convert(it) } ?: marketsListItemUM
}
},
)
}
}
}
currentCoroutineContext().ensureActive()
ResultBatches(
uiBatches = outItems,
processedItems = newList,
)
}
}
fun reload(searchText: String? = null) {
modelScope.launch {
resultBatches.value = ResultBatches()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
fiatPriceCurrency = appCurrency.value.code,
searchText = if (currentSearchText() == null) {
null
} else {
searchText ?: currentSearchText()
},
priceChangeInterval = TokenMarketListConfig.Interval.H24,
order = order,
shouldNetworks = true,
),
),
)
}
}
fun loadMore() {
modelScope.launch {
actionsFlow.emit(BatchAction.LoadMore())
}
}
fun loadCharts(batchKeys: Set<Int>) {
if (batchKeys.isEmpty()) return
modelScope.launch {
val currentData = batchFlow.state.value.data
val alreadyLoadedChartsBatchKeys = currentData
.filter { batch ->
val first = batch.data.firstOrNull() ?: return@filter false
first.tokenCharts.h24 != null
}
.map { it.key }
.toSet()
val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys)
if (batchesKeysToLoad.isNotEmpty()) {
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchesKeysToLoad,
updateRequest = TokenMarketUpdateRequest.UpdateChart(
interval = TokenMarketListConfig.Interval.H24,
currency = appCurrency.value.code,
),
async = true,
operationId = batchesKeysToLoad.toString() + "h24",
),
)
}
}
}
fun getBatchKeysByItemIds(ids: List<CryptoCurrency.RawID>): Set<Int> {
val currentData = batchFlow.state.value.data
return currentData
.filter { d -> d.data.any { ids.contains(it.id) } }
.map { it.key }
.toSet()
}
fun getTokenMarketById(id: CryptoCurrency.RawID): TokenMarket? {
return batchFlow.state.value.data
.asSequence()
.flatMap { it.data }
.firstOrNull { it.id == id }
}
private data class ResultBatches(
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
@AssistedFactory
interface Factory {
fun create(
batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
order: TokenMarketListConfig.Order,
currentSearchText: Provider<String?>,
modelScope: CoroutineScope,
): MarketsListBatchFlowManager
}
}

View file

@ -0,0 +1,161 @@
package com.tangem.features.commonfeatures.impl.choosetoken.market.converter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.compact
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.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.commonfeatures.impl.R
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
internal class SwapMarketsTokenItemConverter(
private val appCurrency: AppCurrency,
) : Converter<TokenMarket, MarketsListItemUM> {
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
override fun convert(value: TokenMarket): MarketsListItemUM {
return MarketsListItemUM(
id = value.id,
name = value.name,
currencySymbol = value.symbol,
ratingPosition = value.marketRating?.toString(),
marketCap = value.getMarketCap(),
iconUrl = value.imageUrlLarge,
price = value.getCurrentPrice(),
trendPercentText = value.getTrendPercent(),
trendType = value.getTrendType(),
chartData = value.getChartData(),
isUnder100kMarketCap = value.isUnderMarketCapLimit,
stakingRate = value.yieldRate?.format { percent() }?.let {
resourceReference(R.string.markets_apy_placeholder, wrappedList(it))
},
updateTimestamp = value.updateTimestamp,
networks = value.networks?.map { network ->
MarketsListItemUM.Network(
networkId = network.networkId,
contractAddress = network.contractAddress,
decimalCount = network.decimalCount,
)
},
)
}
fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM {
require(prev.id == new.id) {
"Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]"
}
return prevUI.copy(
name = new.name,
currencySymbol = new.symbol,
ratingPosition = new.marketRating?.toString(),
marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() },
iconUrl = new.imageUrlLarge,
price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) {
new.getCurrentPrice(
prev = prev,
)
},
trendPercentText = ifChanged(
prev.tokenQuotesShort,
new.tokenQuotesShort,
prevUI.trendPercentText,
) { new.getTrendPercent() },
trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() },
chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() },
)
}
private inline fun <T, R> ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R {
return if (force || prev != new) change(new) else prevR
}
private fun TokenMarket.getMarketCap(): String? {
val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null
return value.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).compact(
threeDigitsMethod = true,
)
}
}
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
val prevPrice = prev?.tokenQuotesShort?.currentPrice
val priceText = tokenQuotesShort.currentPrice.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).price()
}
val changeType = if (prevPrice != null) {
if (tokenQuotesShort.currentPrice > prevPrice) {
PriceChangeType.UP
} else {
PriceChangeType.DOWN
}
} else {
null
}
return MarketsListItemUM.Price(
text = priceText,
annotated = tokenQuotesShort.currentPrice.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code,
appCurrencySymbol = appCurrency.symbol,
),
changeType = changeType,
fiatPrice = tokenQuotesShort.currentPrice,
)
}
private fun TokenMarket.getChartData(): MarketChartRawData? {
val chart = tokenCharts.h24
return chart?.let { ct ->
priceAndTimePointValuesConverter.convert(
MarketChartData.Data(
y = ct.priceY.toImmutableList(),
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted(),
)
}
}
@Suppress("MagicNumber")
private fun TokenMarket.getTrendType(): PriceChangeType {
val percent = tokenQuotesShort.h24ChangePercent
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
private fun TokenMarket.getTrendPercent(): String {
val percent = tokenQuotesShort.h24ChangePercent
return percent.format { percent() }
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.features.commonfeatures.impl.choosetoken.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class SwapMarketState {
abstract val marketsTitle: TextReference
abstract val shouldAssetsCount: Boolean
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val total: Int,
val loadMore: () -> Unit,
val onItemClick: (MarketsListItemUM) -> Unit,
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data class Loading(
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data class LoadingError(
val onRetryClicked: () -> Unit,
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data object SearchNothingFound : SwapMarketState() {
override val marketsTitle: TextReference = TextReference.Res(R.string.markets_common_title)
override val shouldAssetsCount: Boolean = true
}
companion object {
val DefaultLoading
get() = Loading(
marketsTitle = TextReference.Res(R.string.feed_trending_now),
shouldAssetsCount = false,
)
val SearchLoading
get() = Loading(
marketsTitle = TextReference.Res(R.string.markets_common_title),
shouldAssetsCount = true,
)
}
}

View file

@ -0,0 +1,121 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class ChooseTokenModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<ChooseTokenComponent.Params>()
private val bridge: ChooseTokenBridge = params.bridge
private val searchQueryState: StateFlow<SearchQuery> = bridge.searchQueryState
private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState
private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
screensSourcesName = bridge.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty(),
)
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot
val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager
private val marketsStateFlow: Flow<SwapMarketState?> = if (bridge.settings.isShowMarketBlock) {
marketBlockDelegate.marketsStateFlow
} else {
flowOf(null)
}
private val initialState: MutableStateFlow<ChooseTokenInitialUM> = MutableStateFlow(getInitState())
val state: StateFlow<ChooseTokenFullUM> = combine(
flow = initialState,
flow2 = bridge.fullPortfolioBlock,
flow3 = marketsStateFlow,
transform = { initial, content, marketBlock ->
ChooseTokenFullUM(
initialUM = initial,
portfolioBlock = content,
marketsBlock = marketBlock,
)
},
).stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = ChooseTokenFullUM(
initialUM = initialState.value,
portfolioBlock = bridge.fullPortfolioBlock.value,
marketsBlock = null,
),
)
init {
addToPortfolioManager.onDismiss.receiveAsFlow()
.onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { addedResult ->
val isSearched = ChooseTokenAnalyticsPayload.IsSearched(isSearchingState)
val chooseTokenResult = ChooseTokenResult(
currency = addedResult.addedCurrency,
account = addedResult.account,
wallet = addedResult.wallet,
analyticsPayload = setOf(isSearched),
)
bridge.onCurrencyChosen(chooseTokenResult)
marketBlockDelegate.addToPortfolioSlot.dismiss()
}
.launchIn(modelScope)
}
fun onBackClicked() {
bridge.onClose()
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = { query ->
initialState.update { prevState -> SearchBarUpdateQueryTransformer(query).transform(prevState) }
bridge.onSearchQuery(query)
},
onActiveChange = { isActive ->
initialState.update { prevState -> SearchBarToggleTransformer(isActive).transform(prevState) }
},
)
private fun getInitState() = ChooseTokenInitialUM(
screenTitle = bridge.settings.title,
onCloseClick = ::onBackClicked,
searchBar = getInitialSearchBar(),
)
companion object {
const val DEBOUNCE_SEARCH_DELAY = 500L
}
}

View file

@ -0,0 +1,223 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlin.collections.filter
import kotlin.collections.map
import kotlin.collections.orEmpty
@Suppress("LongParameterList")
internal class MarketBlockDelegate @AssistedInject constructor(
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val screensSourcesName: String,
) {
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(
scope = modelScope,
settings = AddToPortfolioManager.Settings.ChooseToken,
analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
)
val marketsStateFlow: Flow<SwapMarketState> = searchQueryState
// Switch between default and search market flows
.map { it.value.isEmpty() }
.distinctUntilChanged()
.flatMapLatest { isDefaultMode ->
if (isDefaultMode) {
visibleMarketItemIds.value = emptyList()
createDefaultMarketsFlow()
} else {
visibleDefaultMarketItemIds.value = emptyList()
createSearchMarketsFlow()
}
}
private val defaultMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
order = TokenMarketListConfig.Order.Trending,
currentSearchText = Provider { null },
modelScope = modelScope,
)
}
private val searchMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
order = TokenMarketListConfig.Order.ByRating,
currentSearchText = Provider { searchQueryState.value.value },
modelScope = modelScope,
)
}
init {
// Reload search markets when query changes
searchQueryState
.onEach { searchQuery ->
if (searchQuery.isSearchingState) {
searchMarketsListManager.reload(searchQuery.value)
}
}
.launchIn(modelScope)
// Initial load of default markets
defaultMarketsListManager.reload()
visibleMarketItemIds
.mapNotNull { rawIDS ->
if (rawIDS.isNotEmpty()) {
searchMarketsListManager.getBatchKeysByItemIds(rawIDS)
} else {
null
}
}
.distinctUntilChanged()
.transformLatest<Set<Int>, Unit> { visibleBatchKeys ->
searchMarketsListManager.loadCharts(visibleBatchKeys)
}
.launchIn(modelScope)
visibleDefaultMarketItemIds
.mapNotNull { rawIds ->
if (rawIds.isNotEmpty()) {
defaultMarketsListManager.getBatchKeysByItemIds(rawIds)
} else {
null
}
}.distinctUntilChanged()
.transformLatest<Set<Int>, Unit> { visibleBatchKeys ->
defaultMarketsListManager.loadCharts(visibleBatchKeys)
}
.launchIn(modelScope)
}
private fun createDefaultMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.feed_trending_now)
return combine(
defaultMarketsListManager.uiItems,
defaultMarketsListManager.isInInitialLoadingErrorState,
defaultMarketsListManager.totalCount,
) { uiItems, isError, total ->
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = { defaultMarketsListManager.reload() },
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
uiItems.isEmpty() -> SwapMarketState.DefaultLoading
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { defaultMarketsListManager.loadMore() },
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleDefaultMarketItemIds.value = it },
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
}
}
}
private fun createSearchMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.markets_common_title)
return combine(
flow = searchMarketsListManager.uiItems,
flow2 = searchMarketsListManager.isInInitialLoadingErrorState,
flow3 = searchMarketsListManager.isSearchNotFoundState,
flow4 = searchMarketsListManager.totalCount,
) { uiItems, isError, isSearchNotFound, total ->
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = { searchMarketsListManager.reload(searchQueryState.value.value) },
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
isSearchNotFound -> SwapMarketState.SearchNothingFound
uiItems.isEmpty() -> SwapMarketState.SearchLoading
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { searchMarketsListManager.loadMore() },
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleMarketItemIds.value = it },
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
}
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id) ?: return
val param = tokenMarket.toSerializableParam()
val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = tokenMarket.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
networkId = network.networkId,
coinId = tokenMarket.id.value,
contractAddress = network.contractAddress,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = hasOnlyHotWallets,
)
}?.map { network ->
TokenMarketInfo.Network(
networkId = network.networkId,
isExchangeable = false,
contractAddress = network.contractAddress,
decimalCount = network.decimalCount,
)
}.orEmpty()
addToPortfolioManager.setTokenNetworks(networks)
addToPortfolioManager.setTokenParams(param)
addToPortfolioSlot.activate(AddToPortfolioRoute)
}
@AssistedFactory
interface Factory {
fun create(
searchQueryState: StateFlow<SearchQuery>,
modelScope: CoroutineScope,
screensSourcesName: String,
): MarketBlockDelegate
}
}

View file

@ -0,0 +1,114 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
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.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class PortfolioFullBlockDelegate @AssistedInject constructor(
private val settingContextUseCase: SettingContextUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val dispatchers: CoroutineDispatcherProvider,
private val selectedWalletUseCase: GetSelectedWalletUseCase,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val portfolioListBlockDelegate: PortfolioListBlockDelegate,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
) {
private val isSearchingState: Boolean get() = searchQueryState.isSearchingState
private val onWalletSelected = Channel<UserWalletId>(capacity = Channel.BUFFERED)
val selectedWalletFlow: SharedFlow<UserWallet> = onWalletSelected.receiveAsFlow()
.distinctUntilChanged()
.mapNotNull { walletId -> getUserWalletUseCase.invoke(walletId).getOrNull() }
.shareIn(modelScope, SharingStarted.Eagerly, replay = 1)
val fullPortfolioBlock: StateFlow<ChooseTokenPortfolioFullBlockUM?> = buildFlow()
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = null)
init {
val globalSelectedWallet = selectedWalletUseCase.sync().getOrNull()
val allWallets = getWalletsUseCase.invokeSync().filter { it.isMultiCurrency }
val firstSelectedWallet = when {
globalSelectedWallet?.isMultiCurrency == true -> globalSelectedWallet
allWallets.isNotEmpty() -> allWallets.first()
else -> null
}
if (firstSelectedWallet != null) selectWalletTab(firstSelectedWallet.walletId)
}
private fun buildFlow() = flow {
val walletsFlow = getWalletsUseCase.invokeAsMap()
.map { wallets -> wallets.filterNot { (_, wallet) -> wallet.isLocked } }
val fullPortfolioBlockFlow = combine(
flow = walletsFlow,
flow2 = portfolioListBlockDelegate.portfolioList,
flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(),
flow4 = settingContextUseCase.invoke(),
transform = { allWallets, portfolioList, selectedWalletId, settings ->
val tokensListData = portfolioList[selectedWalletId] ?: return@combine null
val walletsUM = allWallets.entries
.map { (walletId, wallet) ->
val searchResultCount: TextReference? = portfolioList[walletId]?.totalTokensCount
?.toString()
?.let(::stringReference)
?.takeIf { isSearchingState }
WalletTabUM(
text = stringReference(wallet.name),
onClick = { selectWalletTab(walletId) },
isSelected = selectedWalletId == walletId,
count = searchResultCount,
)
}
val walletListUM = if (walletsUM.size != 1) {
WalletListUM(walletsUM.toPersistentList())
} else {
WalletListUM(persistentListOf())
}
ChooseTokenPortfolioFullBlockUM(
walletList = walletListUM,
isBalanceHidden = settings.isBalanceHidden,
isSearching = isSearchingState,
tokensListData = tokensListData,
)
},
)
emitAll(fullPortfolioBlockFlow)
}
fun selectWalletTab(walletId: UserWalletId) {
onWalletSelected.trySend(walletId)
}
@AssistedFactory
interface Factory {
fun create(
modelScope: CoroutineScope,
portfolioListBlockDelegate: PortfolioListBlockDelegate,
searchQueryState: StateFlow<SearchQuery>,
): PortfolioFullBlockDelegate
}
}

View file

@ -0,0 +1,153 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.utils.ChooseTokenExpandedAccountsHolder
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase
import com.tangem.features.commonfeatures.impl.choosetoken.converter.ChooseTokenListItemConverter
import com.tangem.utils.extensions.mapNotNullValues
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class PortfolioListBlockDelegate @AssistedInject constructor(
private val expandedAccountsHolder: ChooseTokenExpandedAccountsHolder,
private val settingContext: SettingContextUseCase,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getWalletsUseCase: GetWalletsUseCase,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val featureSettings: ChooseTokenBridge.Settings,
) : ClickIntents {
private val onTokenItemClick: Channel<Pair<AccountStatus, CryptoCurrencyStatus>> = Channel()
val onTokenChosen: Channel<ChooseTokenResult> = Channel()
val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
MutableStateFlow { _, _ -> true }
val portfolioList: SharedFlow<Map<UserWalletId, TokenListUMData>> = buildDataFlow()
.distinctUntilChanged()
.shareIn(modelScope, SharingStarted.Eagerly, replay = 1)
private fun buildDataFlow(): Flow<Map<UserWalletId, TokenListUMData>> = channelFlow {
val allAccountsFlow: Flow<LinkedHashMap<UserWalletId, AccountStatusList>> =
multiAccountStatusListSupplier.invokeAsMap()
val allWalletsFlow: StateFlow<LinkedHashMap<UserWalletId, UserWallet>> =
getWalletsUseCase.invokeAsMap().stateIn(this)
onTokenItemClick.receiveAsFlow()
.onEach { (account, currencyStatus) ->
onTokenItemClick(
wallet = allWalletsFlow.value[account.accountId.userWalletId] ?: return@onEach,
account = account,
currencyStatus = currencyStatus,
)
}
.launchIn(this)
val expandedAccountsMapFlow = allWalletsFlow
.map { allWallets -> allWallets.values.map { wallet -> wallet.walletId } }
.distinctUntilChanged()
.flatMapLatest { walletIds -> walletIds.toExpandedAccountsMap() }
.distinctUntilChanged()
val finalFlow = combine(
flow = settingContext.invoke(),
flow2 = allAccountsFlow,
flow3 = expandedAccountsMapFlow,
flow4 = searchQueryState,
flow5 = tokenFilter,
transform = { settings, allAccounts, expandedAccountsMap, searchQuery, tokenFilter ->
allAccounts.mapNotNullValues { (walletId, statusList) ->
val expandedAccounts = expandedAccountsMap[walletId].orEmpty()
val converterParams = if (settings.isAccountsMode) {
TokenConverterParams.Account(statusList, expandedAccounts)
} else {
TokenConverterParams.Wallet(statusList.mainAccount, statusList.mainAccount.tokenList)
}
val um = ChooseTokenListItemConverter(
appCurrency = settings.appCurrency,
params = converterParams,
clickIntents = this@PortfolioListBlockDelegate,
searchQuery = searchQuery,
tokenFilter = tokenFilter,
isShowPaymentAccount = featureSettings.isShowPaymentAccount,
).convert()
um
}
},
)
finalFlow.collectLatest { result -> channel.send(result) }
}
private fun List<UserWalletId>.toExpandedAccountsMap(): Flow<Map<UserWalletId, Set<AccountId>>> {
if (isEmpty()) return flowOf(emptyMap())
val flows: List<Flow<Pair<UserWalletId, Set<AccountId>>>> =
map { walletId -> expandedAccountsHolder.expandedAccounts(walletId).map { set -> walletId to set } }
return combine(flows, { pairs -> pairs.toMap() })
}
private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) {
val analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.IsSearched(searchQueryState.isSearchingState),
)
val result = ChooseTokenResult(
account = account,
currency = currencyStatus,
wallet = wallet,
analyticsPayload = analyticsPayload,
)
onTokenChosen.trySend(result)
}
override fun onTokenItemClick(account: AccountStatus, currencyStatus: CryptoCurrencyStatus) {
onTokenItemClick.trySend(account to currencyStatus)
}
override fun onAccountExpandClick(account: Account) {
expandedAccountsHolder.expandAccount(account.accountId)
}
override fun onAccountCollapseClick(account: Account) {
expandedAccountsHolder.collapseAccount(account.accountId)
}
@AssistedFactory
interface Factory {
fun create(
searchQueryState: StateFlow<SearchQuery>,
modelScope: CoroutineScope,
featureSettings: ChooseTokenBridge.Settings,
): PortfolioListBlockDelegate
}
}
internal interface ClickIntents {
fun onTokenItemClick(account: AccountStatus, currencyStatus: CryptoCurrencyStatus)
fun onAccountExpandClick(account: Account)
fun onAccountCollapseClick(account: Account)
}

View file

@ -0,0 +1,498 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.tokens.portfolioTokensList
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.token.AccountItemPreviewData
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.TokenListItem
import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlin.random.Random
private const val LOAD_MORE_BUFFER = 25
private val ChooseTokenFullUM.isNotFoundState: Boolean
get() {
if (portfolioBlock == null) return false
if (marketsBlock == null) return false
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
portfolioBlock.isSearching &&
marketsBlock !is SwapMarketState.Content &&
marketsBlock !is SwapMarketState.Loading
}
private val ChooseTokenFullUM.isEmptyState: Boolean
get() {
if (portfolioBlock == null) return false
if (marketsBlock == null) return false
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
!portfolioBlock.isSearching &&
marketsBlock !is SwapMarketState.Content &&
marketsBlock !is SwapMarketState.Loading
}
@Composable
internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBar(title = state.initialUM.screenTitle, onBackClick = state.initialUM.onCloseClick, Modifier)
Content(
state = state,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun AppBar(title: TextReference, onBackClick: () -> Unit, modifier: Modifier = Modifier) {
AppBarWithBackButton(
text = title.resolveReference(),
onBackClick = onBackClick,
iconRes = com.tangem.common.ui.R.drawable.ic_back_24,
modifier = modifier
.statusBarsPadding()
.height(TangemTheme.dimens.size56),
)
}
@Composable
private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection()
val lazyListState = rememberLazyListState()
TangemSharedTransitionLayout(modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
state = lazyListState,
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
item(key = "search_bar") {
SearchBar(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
state = state.initialUM.searchBar,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
}
assetsTitle()
if (state.portfolioBlock != null) {
walletListItem(state.portfolioBlock.walletList)
when {
state.isNotFoundState -> tokensNotFound()
state.isEmptyState -> emptyTokensList()
else -> {
tokensListItems(
tokensListData = state.portfolioBlock.tokensListData,
isBalanceHidden = state.portfolioBlock.isBalanceHidden,
)
if (state.marketsBlock != null) {
item("markets_title_spacer") { SpacerH(height = 20.dp) }
swapMarketsListItems(state.marketsBlock)
}
}
}
}
}
}
if (state.marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) {
SetupMarketScrollTracker(state.marketsBlock, lazyListState)
}
}
@Composable
private fun SetupMarketScrollTracker(marketsState: SwapMarketState, lazyListState: LazyListState) {
if (marketsState !is SwapMarketState.Content) return
val onLoadMore = remember(marketsState) {
{
marketsState.loadMore()
true
}
}
VisibleItemsTracker(
lazyListState = lazyListState,
marketState = marketsState,
)
InfiniteListHandler(
listState = lazyListState,
buffer = LOAD_MORE_BUFFER,
triggerLoadMoreCheckOnItemsCountChange = true,
onLoadMore = onLoadMore,
)
}
@Composable
private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) {
val visibleItems by remember {
derivedStateOf {
lazyListState.layoutInfo.visibleItemsInfo.mapNotNull { itemInfo ->
marketState.items.find { it.getComposeKey() == itemInfo.key }?.id
}
}
}
LaunchedEffect(visibleItems) {
marketState.visibleIdsChanged(visibleItems)
}
}
private fun LazyListScope.assetsTitle() {
item(key = "assets_title") {
Text(
text = stringResourceSafe(R.string.swap_your_assets_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing20,
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
),
)
}
}
private fun LazyListScope.walletListItem(walletList: WalletListUM) {
if (walletList.items.isEmpty()) return
item("wallet_list") {
LazyRow(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(walletList.items) { um ->
WalletTabItem(um)
}
}
}
}
@Composable
private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
val isSelected = state.isSelected
val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary
val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1
val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary
val countBackground = if (isSelected) {
TangemTheme.colors.button.secondary.copy(alpha = 0.2f)
} else {
TangemTheme.colors.button.primary.copy(alpha = 0.1f)
}
Row(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(backgroundColor)
.clickable(onClick = state.onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = state.text.resolveReference(),
color = buttonTextColor,
style = TangemTheme.typography.button,
)
val count = state.count
if (count != null) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.background(countBackground, shape = CircleShape)
.defaultMinSize(minWidth = 20.dp)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = count.resolveReference(),
color = countTextColor,
style = TangemTheme.typography.caption1,
)
}
}
}
}
private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) {
when (tokensListData) {
is TokenListUMData.AccountList -> {
tokensListData.tokensList.forEachIndexed { index, item ->
portfolioTokensList(
portfolio = item,
portfolioIndex = index,
isBalanceHidden = isBalanceHidden,
testTag = BuyTokenScreenTestTags.LAZY_LIST_ITEM,
)
}
}
is TokenListUMData.TokenList -> {
tokensList(
items = tokensListData.tokensList,
isBalanceHidden = isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
}
}
private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isBalanceHidden: Boolean) {
itemsIndexed(
items = items,
key = { _, item -> item.id },
contentType = { _, item -> item::class.java },
itemContent = { index, item ->
TokenListItem(
state = item,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = items.lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = index },
)
},
)
}
private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) {
item("EmptyTokensList") {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.fillParentMaxSize(),
) {
Column(modifier = Modifier.align(Alignment.Center)) {
Image(
modifier = Modifier
.size(TangemTheme.dimens.size64)
.align(Alignment.CenterHorizontally),
painter = painterResource(id = R.drawable.ic_no_token_44),
colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive),
contentDescription = null,
)
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing30)
.align(Alignment.CenterHorizontally),
text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
}
}
private fun LazyListScope.tokensNotFound(modifier: Modifier = Modifier) {
item("TokensNotFound") {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.fillParentMaxSize(),
) {
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32)
.padding(horizontal = TangemTheme.dimens.spacing30)
.align(Alignment.TopCenter),
text = stringResourceSafe(id = R.string.express_token_list_empty_search),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
}
@Preview
@Composable
private fun TokenScreenPreview(@PreviewParameter(ChooseTokenScreenPreviewProvider::class) state: ChooseTokenFullUM) {
TangemThemePreview {
ChooseTokenScreen(
state = state,
modifier = Modifier,
)
}
}
private val searchBar
get() = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
)
private val tokenItem
get() = TokenItemState.Content(
id = Random.nextInt().toString(),
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "12 368,14 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "0,35853044 BTC"),
subtitleState = TokenItemState.SubtitleState.CryptoPriceContent(
price = "34 496,75 \$",
priceChangePercent = "0,43 %",
type = PriceChangeType.DOWN,
),
onItemClick = {},
onItemLongClick = {},
)
private val tokens
get() = persistentListOf(
TokensListItemUM.GroupTitle(id = 111, text = stringReference("Network Bitcoin")),
TokensListItemUM.Token(state = tokenItem),
TokensListItemUM.GroupTitle(id = 222, text = stringReference("Network Ethereum")),
TokensListItemUM.Token(state = tokenItem),
TokensListItemUM.Token(state = tokenItem),
TokensListItemUM.Token(state = tokenItem),
)
private val accounts
get() = persistentListOf(
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Tokens(
tokens = tokens.filterIsInstance<PortfolioTokensListItemUM>().toPersistentList(),
),
isExpanded = false,
isCollapsable = true,
tokenItemUM = AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon),
),
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Tokens(
tokens = tokens.filterIsInstance<PortfolioTokensListItemUM>().toPersistentList(),
),
isExpanded = true,
isCollapsable = true,
tokenItemUM = AccountItemPreviewData.accountItem,
),
)
private val wallets
get() = persistentListOf(
WalletTabUM(
text = TextReference.Str(value = "Wallet 1"),
isSelected = true,
onClick = {},
count = null,
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 1"),
isSelected = true,
onClick = {},
count = stringReference("3"),
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 2"),
isSelected = false,
onClick = {},
count = stringReference("333"),
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 3"),
isSelected = false,
onClick = {},
count = null,
),
)
private val initialUM = ChooseTokenInitialUM(
screenTitle = stringReference("Choose token"),
onCloseClick = {},
searchBar = searchBar,
)
private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<ChooseTokenFullUM> {
override val values: Sequence<ChooseTokenFullUM> = sequenceOf(
ChooseTokenFullUM(
initialUM = initialUM,
portfolioBlock = ChooseTokenPortfolioFullBlockUM(
walletList = WalletListUM(wallets),
isBalanceHidden = false,
isSearching = false,
tokensListData = TokenListUMData.AccountList(
tokensList = accounts,
accounts.size,
),
),
marketsBlock = SwapSelectTokenPreviewProvider.marketState,
),
ChooseTokenFullUM(
initialUM = initialUM,
portfolioBlock = ChooseTokenPortfolioFullBlockUM(
walletList = WalletListUM(wallets),
isBalanceHidden = false,
isSearching = false,
tokensListData = TokenListUMData.EmptyList,
),
marketsBlock = null,
),
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
internal data class ChooseTokenFullUM(
val initialUM: ChooseTokenInitialUM,
val portfolioBlock: ChooseTokenPortfolioFullBlockUM?,
val marketsBlock: SwapMarketState?,
)
internal data class ChooseTokenInitialUM(
val screenTitle: TextReference,
val onCloseClick: () -> Unit,
val searchBar: SearchBarUM,
)

View file

@ -0,0 +1,106 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
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.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
item {
val totalCount = (state as? SwapMarketState.Content)?.total
Text(
text = buildAnnotatedString {
append(state.marketsTitle.resolveReference())
if (totalCount != null) {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $totalCount")
}
}
},
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16),
)
}
when (state) {
is SwapMarketState.Loading -> {
items(count = 100, key = { "market_placeholder_$it" }) {
MarketsListItemPlaceholder()
}
}
is SwapMarketState.LoadingError -> {
item(key = "market_loading_error") {
LoadingErrorItem(
modifier = Modifier.fillParentMaxWidth(),
onTryAgain = state.onRetryClicked,
)
}
}
SwapMarketState.SearchNothingFound -> {
item(key = "market_not_found") {
SearchNothingFoundText(
modifier = Modifier.fillParentMaxWidth(),
)
}
}
is SwapMarketState.Content -> {
itemsIndexed(
items = state.items,
key = { _, item -> item.getComposeKey() },
) { index, item ->
MarketsListItem(
model = item,
onClick = { state.onItemClick(item) },
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors.background.action,
),
)
}
}
}
}
@Composable
private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = onTryAgain)
}
}
@Composable
private fun SearchNothingFoundText(modifier: Modifier = Modifier) {
Box(
modifier = modifier.padding(TangemTheme.dimens.spacing16),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResourceSafe(R.string.markets_search_token_no_result_title),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
}
}

View file

@ -0,0 +1,125 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
internal object SwapSelectTokenPreviewProvider {
private const val CHART_VALUE_1 = 0.4
private const val CHART_VALUE_2 = 0.2
private const val CHART_VALUE_3 = 0.1
private const val CHART_VALUE_4 = 2.0
private const val CHART_VALUE_5 = 5.0
private const val CHART_VALUE_6 = 3.0
private const val TOTAL_ITEMS = 322
private val PREVIEW_CHART_DATA = MarketChartRawData(
y = persistentListOf(
CHART_VALUE_1,
CHART_VALUE_2,
CHART_VALUE_1,
CHART_VALUE_3,
CHART_VALUE_1,
CHART_VALUE_4,
CHART_VALUE_5,
CHART_VALUE_3,
CHART_VALUE_4,
CHART_VALUE_4,
CHART_VALUE_6,
),
)
val marketState = SwapMarketState.Content(
items = createPreviewMarketItems(),
loadMore = { },
onItemClick = { },
visibleIdsChanged = { },
total = TOTAL_ITEMS,
marketsTitle = TextReference.Res(R.string.feed_trending_now),
shouldAssetsCount = false,
)
private fun createPreviewMarketItems() = listOf(
createMarketItem(
id = "1",
iconUrl = "",
ratingPosition = "10",
marketCap = "$6.233 B",
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "2",
ratingPosition = "10",
marketCap = "$6.233 B",
trendType = PriceChangeType.NEUTRAL,
chartData = null,
),
createMarketItem(
id = "3",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
ratingPosition = "10",
marketCap = "$6.23348172384781234 B",
trendType = PriceChangeType.DOWN,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "4",
ratingPosition = "10",
marketCap = null,
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "5",
ratingPosition = null,
marketCap = "$6.233 B",
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "6",
ratingPosition = null,
marketCap = null,
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
).toImmutableList()
private fun createMarketItem(
id: String,
name: String = "Bitcoin",
iconUrl: String? = null,
ratingPosition: String?,
marketCap: String?,
trendType: PriceChangeType,
chartData: MarketChartRawData?,
) = MarketsListItemUM(
id = CryptoCurrency.RawID(id),
name = name,
currencySymbol = "BTC",
iconUrl = iconUrl,
ratingPosition = ratingPosition,
marketCap = marketCap,
price = MarketsListItemUM.Price(
text = "31 285.72$",
annotated = stringReference("31 285.72$"),
fiatPrice = BigDecimal("123123"),
),
trendPercentText = "12.43%",
trendType = trendType,
chartData = chartData,
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
)
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.commonfeatures.impl.portfolioselector
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorBS
import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorContent
import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorContentV2
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
internal class DefaultPortfolioSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: PortfolioSelectorComponent.Params,
) : AppComponentContext by appComponentContext, PortfolioSelectorComponent {
private val model: PortfolioSelectorModel = getOrCreateModel(params)
override val title: StateFlow<TextReference>
get() = model.state
.map { it.title }
.stateIn(componentScope, SharingStarted.Lazily, model.state.value.title)
override fun dismiss() {
params.bsCallback?.onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
PortfolioSelectorBS(state = state, onDismiss = ::dismiss, onBack = { params.bsCallback?.onBack() })
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val listBottomPadding = PaddingValues(bottom = LocalTangemBottomSheetContentBottomInset.current)
if (LocalRedesignEnabled.current) {
PortfolioSelectorContentV2(
state = state,
modifier = modifier,
contentPadding = listBottomPadding,
)
} else {
PortfolioSelectorContent(
state = state,
modifier = modifier,
contentPadding = listBottomPadding,
)
}
}
@AssistedFactory
interface Factory : PortfolioSelectorComponent.Factory {
override fun create(
appComponentContext: AppComponentContext,
params: PortfolioSelectorComponent.Params,
): DefaultPortfolioSelectorComponent
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.commonfeatures.impl.portfolioselector
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
internal class DefaultPortfolioSelectorController @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
) : PortfolioSelectorController {
private val _selectedAccount: MutableSharedFlow<AccountId?> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val isAccountMode: Flow<Boolean> by lazy { isAccountsModeEnabledUseCase() }
// without StateFlow and distinctUntilChanged to allow reselect and correct navigation
override val selectedAccount: Flow<AccountId?> get() = _selectedAccount
override val selectedAccountSync: AccountId? get() = _selectedAccount.replayCache.firstOrNull()
override val isEnabled: MutableStateFlow<(UserWallet, AccountStatus) -> Boolean> = MutableStateFlow { _, _ -> true }
override suspend fun isAccountModeSync(): Boolean = isAccountsModeEnabledUseCase.invokeSync()
override fun selectAccount(accountId: AccountId?) {
_selectedAccount.tryEmit(accountId)
}
override fun selectedAccountWithData(
portfolioFetcher: PortfolioFetcher,
): Flow<Pair<UserWallet, AccountStatus.CryptoPortfolio>?> = combine(
flow = _selectedAccount,
flow2 = portfolioFetcher.data,
transform = { accountId, data ->
accountId ?: return@combine null
var result: Pair<UserWallet, AccountStatus.CryptoPortfolio>? = null
data.balances.forEach { wallet, balance ->
val accountStatuses = balance.accountsBalance.accountStatuses
.filterCryptoPortfolio()
.find { accountId == it.account.accountId }
if (accountStatuses != null) result = balance.userWallet to accountStatuses
}
return@combine result
},
)
}

View file

@ -0,0 +1,218 @@
package com.tangem.features.commonfeatures.impl.portfolioselector
import com.tangem.common.ui.account.AccountPortfolioItemUMConverter
import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM
import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM
import com.tangem.features.wallet.utils.UserWalletImageFetcher
import com.tangem.operations.attestation.ArtworkSize
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ModelScoped
internal class PortfolioSelectorModel @Inject constructor(
paramsContainer: ParamsContainer,
walletImageFetcher: UserWalletImageFetcher,
isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val params = paramsContainer.require<PortfolioSelectorComponent.Params>()
private val balanceFetcher get() = params.portfolioFetcher
private val selectorController get() = params.controller
internal val state: StateFlow<PortfolioSelectorUM>
field = MutableStateFlow<PortfolioSelectorUM>(emptyState())
init {
val selectedAccountState = selectorController.selectedAccount
.stateIn(modelScope, started = SharingStarted.Eagerly, initialValue = null)
combine(
flow = isAccountsModeEnabledUseCase(),
flow2 = balanceFetcher.data,
flow3 = walletImageFetcher.allWallets(ArtworkSize.SMALL),
flow4 = selectorController.isEnabled,
flow5 = selectedAccountState,
transform = { isAccountsMode, portfolioData, artworks, isEnabled, selectedAccount ->
val uiList = buildUiList(
isAccountsMode = isAccountsMode,
portfolioData = portfolioData,
artworks = artworks,
isEnabled = isEnabled,
selectedAccount = selectedAccount,
)
val title = when (isAccountsMode) {
true -> resourceReference(R.string.common_choose_account)
false -> resourceReference(R.string.common_choose_wallet)
}
state.value = PortfolioSelectorUM(
title = title,
items = uiList.toImmutableList(),
)
},
)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun buildUiList(
isAccountsMode: Boolean,
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
selectedAccount: AccountId?,
): List<PortfolioSelectorItemUM> = when (isAccountsMode) {
true -> buildAccountsList(
portfolioData = portfolioData,
artworks = artworks,
isEnabled = isEnabled,
selectedAccount = selectedAccount,
)
false -> buildWalletList(
portfolioData = portfolioData,
artworks = artworks,
isEnabled = isEnabled,
selectedAccount = selectedAccount,
)
}
private fun buildWalletList(
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
selectedAccount: AccountId?,
): List<PortfolioSelectorItemUM> = buildList {
val appCurrency = portfolioData.appCurrency
val isBalanceHidden = portfolioData.isBalanceHidden
val lockedWallets = mutableListOf<PortfolioSelectorItemUM>()
portfolioData.balances.forEach { (_, portfolio) ->
val balance = portfolio.walletBalance
val wallet = portfolio.userWallet
val walletItemUM = UserWalletItemUMConverter(
onClick = {
selectorController.selectAccount(portfolio.accountsBalance.mainAccount.account.accountId)
},
appCurrency = appCurrency,
balance = balance,
isBalanceHidden = isBalanceHidden,
artwork = artworks[wallet.walletId],
isAuthMode = false,
mode = UserWalletItemUMConverter.InfoField.Tokens(
tokensCount = portfolio.accountsBalance.flattenCurrencies().size,
),
).convert(wallet)
if (walletItemUM.isEnabled) {
val mainAccount = portfolio.accountsBalance.mainAccount
val isEnabledByFeature = isEnabled(wallet, mainAccount)
val finalWalletItemUM =
if (isEnabledByFeature) walletItemUM else walletItemUM.copy(isEnabled = false)
val isSelected = mainAccount.isSelected(selectedAccount)
add(PortfolioSelectorItemUM.Portfolio(finalWalletItemUM, isSelected))
} else {
lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM, isSelected = false))
}
}
if (lockedWallets.isNotEmpty()) {
addAll(createLockedWallets(wallets = lockedWallets))
}
}
private fun buildAccountsList(
portfolioData: PortfolioFetcher.Data,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
isEnabled: (UserWallet, AccountStatus) -> Boolean,
selectedAccount: AccountId?,
): List<PortfolioSelectorItemUM> = buildList {
val appCurrency = portfolioData.appCurrency
val isBalanceHidden = portfolioData.isBalanceHidden
val lockedWallets = mutableListOf<PortfolioSelectorItemUM>()
portfolioData.balances.forEach { (_, portfolio) ->
val balance = portfolio.walletBalance
val wallet = portfolio.userWallet
val walletItemUM = UserWalletItemUMConverter(
onClick = {
selectorController.selectAccount(portfolio.accountsBalance.mainAccount.account.accountId)
},
appCurrency = appCurrency,
balance = balance,
isBalanceHidden = isBalanceHidden,
artwork = artworks[wallet.walletId],
isAuthMode = false,
).convert(wallet)
if (!walletItemUM.isEnabled) {
lockedWallets.add(PortfolioSelectorItemUM.Portfolio(walletItemUM, false))
return@forEach
}
when (balanceFetcher.mode.value) {
// for Wallet mode expected single portfolioData.balances
is PortfolioFetcher.Mode.Wallet -> Unit
is PortfolioFetcher.Mode.All -> {
if (portfolioData.balances.size > 1) {
val walletTitle = PortfolioSelectorItemUM.GroupTitle(
id = "GroupTitle ${wallet.walletId.stringValue}",
name = stringReference(wallet.name),
)
add(walletTitle)
}
}
}
portfolio.accountsBalance.accountStatuses.filterCryptoPortfolio().forEach { accountStatus ->
val isEnabledByFeature = isEnabled(wallet, accountStatus)
val account = accountStatus.account
val accountBalance = accountStatus.tokenList.totalFiatBalance
val accountItemUM = AccountPortfolioItemUMConverter(
onClick = { selectorController.selectAccount(account.accountId) },
appCurrency = appCurrency,
accountBalance = accountBalance,
isEnabled = isEnabledByFeature,
isBalanceHidden = isBalanceHidden,
).convert(account)
val isSelected = accountStatus.isSelected(selectedAccount)
add(PortfolioSelectorItemUM.Portfolio(accountItemUM, isSelected))
}
}
if (lockedWallets.isNotEmpty()) {
addAll(createLockedWallets(wallets = lockedWallets))
}
}
private fun createLockedWallets(wallets: List<PortfolioSelectorItemUM>): List<PortfolioSelectorItemUM> {
val lockedWalletsTitle = PortfolioSelectorItemUM.GroupTitle(
id = "lockedWalletsTitleId",
name = resourceReference(R.string.common_locked_wallets),
)
return listOf(lockedWalletsTitle) + wallets
}
private fun AccountStatus.isSelected(selectedId: AccountId?) = this.account.accountId == selectedId
private fun emptyState() = PortfolioSelectorUM(
items = persistentListOf(),
title = TextReference.EMPTY,
)
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.commonfeatures.impl.portfolioselector.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
import com.tangem.features.commonfeatures.impl.portfolioselector.DefaultPortfolioSelectorComponent
import com.tangem.features.commonfeatures.impl.portfolioselector.DefaultPortfolioSelectorController
import com.tangem.features.commonfeatures.impl.portfolioselector.PortfolioSelectorModel
import com.tangem.features.commonfeatures.impl.portfolioselector.fetcher.DefaultPortfolioFetcher
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface PortfolioSelectorModule {
@Binds
@IntoMap
@ClassKey(PortfolioSelectorModel::class)
fun portfolioSelectorModel(model: PortfolioSelectorModel): Model
}
@Module
@InstallIn(SingletonComponent::class)
internal interface PortfolioSelectorSingletonModule {
@Binds
fun bindPortfolioFetcherFactory(impl: DefaultPortfolioFetcher.Factory): PortfolioFetcher.Factory
@Binds
fun bindPortfolioSelectorController(impl: DefaultPortfolioSelectorController): PortfolioSelectorController
@Binds
fun bindPortfolioSelectorComponentFactory(
impl: DefaultPortfolioSelectorComponent.Factory,
): PortfolioSelectorComponent.Factory
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.commonfeatures.impl.portfolioselector.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
data class PortfolioSelectorUM(
val title: TextReference,
val items: ImmutableList<PortfolioSelectorItemUM>,
)
@Immutable
sealed interface PortfolioSelectorItemUM {
val id: String
data class GroupTitle(
override val id: String,
val name: TextReference,
) : PortfolioSelectorItemUM
data class Portfolio(
val item: UserWalletItemUM,
val isSelected: Boolean,
) : PortfolioSelectorItemUM {
override val id: String = item.id
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.features.commonfeatures.impl.portfolioselector.fetcher
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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.*
@Suppress("LongParameterList")
internal class DefaultPortfolioFetcher @AssistedInject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getWallets: GetWalletsUseCase,
dispatchers: CoroutineDispatcherProvider,
@Assisted mode: Mode,
@Assisted private val scope: CoroutineScope,
) : PortfolioFetcher {
private val _mode = MutableStateFlow(mode)
private val _data = MutableSharedFlow<Data>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val data: Flow<Data>
get() = _data.distinctUntilChanged()
override val mode: StateFlow<Mode>
get() = _mode
init {
_mode
// reset cache if mode(StateFlow) changed
.onEach { _data.resetReplayCache() }
.flatMapLatest(::combineUseCases)
.flowOn(dispatchers.default)
.onEach { _data.emit(it) }
.launchIn(scope)
}
override fun updateMode(mode: Mode) {
_mode.value = mode
}
private fun combineUseCases(mode: Mode) = combine(
flow = getWallets()
.map { it.filterWallets(mode) }
.distinctUntilChanged()
.flatMapLatest { wallets -> balancesForWallets(wallets) },
flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { balances, appCurrency, isBalanceHiding ->
Data(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHiding,
balances = balances,
)
}
private fun List<UserWallet>.filterWallets(mode: Mode): List<UserWallet> = this.filter { wallet ->
when (mode) {
is Mode.All -> if (mode.isOnlyMultiCurrency) wallet.isMultiCurrency else true
is Mode.Wallet -> wallet.walletId == mode.walletId
}
}
private fun balancesForWallets(wallets: List<UserWallet>): Flow<Map<UserWalletId, PortfolioBalance>> {
if (wallets.isEmpty()) return flowOf(emptyMap())
val balanceFlows = wallets.map { walletAccountsBalancesFlow(it) }
return combine(balanceFlows) { pairs -> pairs.toMap() }
}
private fun walletAccountsBalancesFlow(wallet: UserWallet): Flow<Pair<UserWalletId, PortfolioBalance>> =
accountStatusListFlow(wallet).map { wallet.walletId to PortfolioBalance(wallet, it) }
private fun accountStatusListFlow(wallet: UserWallet): Flow<AccountStatusList> =
singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(wallet.walletId))
@AssistedFactory
interface Factory : PortfolioFetcher.Factory {
override fun create(mode: Mode, scope: CoroutineScope): DefaultPortfolioFetcher
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.features.commonfeatures.impl.portfolioselector.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM
@Composable
internal fun PortfolioSelectorBS(
state: PortfolioSelectorUM,
onDismiss: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
onBack = onBack,
scrollableContent = false,
containerColor = TangemTheme.colors.background.tertiary,
title = {
TangemModalBottomSheetTitle(
title = state.title,
startIconRes = R.drawable.ic_back_24,
onStartClick = onBack,
)
},
content = {
if (LocalRedesignEnabled.current) {
PortfolioSelectorContentV2(
state = state,
contentPadding = PaddingValues(bottom = 16.dp),
modifier = modifier.padding(horizontal = 16.dp),
)
} else {
PortfolioSelectorContent(
state = state,
contentPadding = PaddingValues(bottom = 16.dp),
modifier = modifier.padding(horizontal = 16.dp),
)
}
},
)
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::class) params: PortfolioSelectorUM) {
TangemThemePreview {
PortfolioSelectorBS(
state = params,
onDismiss = {},
modifier = Modifier,
onBack = {},
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewV2(@PreviewParameter(PortfolioSelectorPreviewStateProvider::class) params: PortfolioSelectorUM) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
PortfolioSelectorBS(
state = params,
onDismiss = {},
modifier = Modifier,
onBack = {},
)
}
}
}

View file

@ -0,0 +1,221 @@
package com.tangem.features.commonfeatures.impl.portfolioselector.ui
import android.content.res.Configuration
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.userwallet.UserWalletItemRow
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.common.ui.userwallet.state.UserWalletItemUM.ImageState
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM
import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM
import kotlinx.collections.immutable.toImmutableList
import java.util.UUID
private const val DISABLED_WALLET_ALPHA = 0.5f
@Composable
internal fun PortfolioSelectorContent(
state: PortfolioSelectorUM,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
) {
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
) {
val items = state.items
itemsIndexed(
items = items,
key = { _, item -> item.id },
) { index, item ->
val previewItem = items.getOrNull(index.dec())
val offsetModifier = when {
previewItem == null -> Modifier
item is PortfolioSelectorItemUM.GroupTitle -> Modifier.padding(
top = TangemTheme.dimens.spacing16,
)
else -> Modifier.padding(
top = TangemTheme.dimens.spacing8,
)
}
val portfolioShape = RoundedCornerShape(TangemTheme.dimens.radius14)
val border = BorderStroke(
width = 1.dp,
color = TangemTheme.colors.text.accent,
)
when (item) {
is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow(
state = item.item,
modifier = offsetModifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size68)
.clip(portfolioShape)
.background(TangemTheme.colors.background.action)
.conditional(item.isSelected) { border(border, portfolioShape) }
.clickable(enabled = item.item.isEnabled, onClick = item.item.onClick)
.padding(all = TangemTheme.dimens.spacing12)
.conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) },
)
is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow(
model = item,
modifier = offsetModifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
)
}
}
}
}
@Composable
private fun WalletNameRow(model: PortfolioSelectorItemUM.GroupTitle, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = model.name.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::class) params: PortfolioSelectorUM) {
TangemThemePreview {
PortfolioSelectorContent(
state = params,
modifier = Modifier.background(color = TangemTheme.colors.background.tertiary),
)
}
}
internal object PortfolioSelectorPreviewData {
val accountName get() = stringReference(value = "Portfolio")
val walletName get() = stringReference(value = "Tangem 2.0")
private val accountItem: UserWalletItemUM
get() = UserWalletItemUM(
id = UUID.randomUUID().toString(),
name = accountName,
information = UserWalletItemUM.Information.Loaded(stringReference("12 tokens")),
balance = UserWalletItemUM.Balance.Loaded("$726.04", false),
isEnabled = true,
onClick = { },
imageState = ImageState.Account(
name = accountName,
icon = AccountIconPreviewData.randomAccountIcon(),
),
)
private val lockedAccountItem: UserWalletItemUM
get() = accountItem.copy(isEnabled = false)
private val walletItem: UserWalletItemUM
get() = UserWalletItemUM(
id = UUID.randomUUID().toString(),
name = walletName,
information = UserWalletItemUM.Information.Loaded(stringReference("12 tokens")),
balance = UserWalletItemUM.Balance.Loaded("$726.04", false),
isEnabled = true,
onClick = { },
imageState = ImageState.MobileWallet,
)
private val lockedWalletItem: UserWalletItemUM
get() = walletItem.copy(isEnabled = false)
val firstList
get() = listOf(
PortfolioSelectorItemUM.GroupTitle(
id = UUID.randomUUID().toString(),
name = stringReference("Tangem 2.0"),
),
PortfolioSelectorItemUM.Portfolio(accountItem, false),
PortfolioSelectorItemUM.Portfolio(lockedAccountItem, false),
PortfolioSelectorItemUM.GroupTitle(
id = UUID.randomUUID().toString(),
name = stringReference("Tangem White"),
),
PortfolioSelectorItemUM.Portfolio(accountItem, true),
)
val secondList
get() = firstList + listOf(
PortfolioSelectorItemUM.GroupTitle(
id = UUID.randomUUID().toString(),
name = resourceReference(R.string.common_locked_wallets),
),
PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false),
PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false),
)
val walletList
get() = listOf(
PortfolioSelectorItemUM.Portfolio(walletItem, false),
PortfolioSelectorItemUM.Portfolio(walletItem, true),
)
val lockedWalletList
get() = listOf(
PortfolioSelectorItemUM.Portfolio(walletItem, true),
PortfolioSelectorItemUM.GroupTitle(
id = UUID.randomUUID().toString(),
name = resourceReference(R.string.common_locked_wallets),
),
PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false),
PortfolioSelectorItemUM.Portfolio(lockedWalletItem, false),
)
}
internal class PortfolioSelectorPreviewStateProvider : CollectionPreviewParameterProvider<PortfolioSelectorUM>(
listOf(
PortfolioSelectorUM(
title = resourceReference(R.string.common_choose_account),
items = PortfolioSelectorPreviewData.firstList.toImmutableList(),
),
PortfolioSelectorUM(
title = resourceReference(R.string.common_choose_account),
items = PortfolioSelectorPreviewData.secondList.toImmutableList(),
),
PortfolioSelectorUM(
title = resourceReference(R.string.common_choose_wallet),
items = PortfolioSelectorPreviewData.walletList.toImmutableList(),
),
PortfolioSelectorUM(
title = resourceReference(R.string.common_choose_wallet),
items = PortfolioSelectorPreviewData.lockedWalletList.toImmutableList(),
),
),
)

View file

@ -0,0 +1,228 @@
package com.tangem.features.commonfeatures.impl.portfolioselector.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.userwallet.CardImage
import com.tangem.common.ui.userwallet.getBalanceValueAndFlickerState
import com.tangem.common.ui.userwallet.getInformationValue
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM
import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM
import com.tangem.utils.StringsSigns.DOT
private const val DISABLED_WALLET_ALPHA = 0.5f
@Composable
internal fun PortfolioSelectorContentV2(
state: PortfolioSelectorUM,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
) {
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
) {
val items = state.items
itemsIndexed(
items = items,
key = { _, item -> item.id },
) { index, item ->
when (item) {
is PortfolioSelectorItemUM.Portfolio ->
PortfolioSelectorItem(
state = item.item,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
radius = TangemTheme.dimens2.x5,
addDefaultPadding = false,
)
.clickable(enabled = item.item.isEnabled, onClick = item.item.onClick)
.conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) },
)
is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow(
model = item,
modifier = Modifier
.fillMaxWidth()
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
radius = TangemTheme.dimens2.x5,
addDefaultPadding = false,
)
.padding(horizontal = TangemTheme.dimens.spacing16),
)
}
}
}
}
@Composable
private fun PortfolioSelectorItem(state: UserWalletItemUM, modifier: Modifier = Modifier) {
TangemRowContainer(modifier = modifier) {
Box(
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3),
contentAlignment = Alignment.Center,
) {
CardImage(
modifier = Modifier.size(40.dp),
imageState = state.imageState,
)
}
Text(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP),
text = state.name.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
InfoRow(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM),
information = state.information,
balance = state.balance,
)
}
}
@Composable
private fun InfoRow(
information: UserWalletItemUM.Information,
balance: UserWalletItemUM.Balance,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
AnimatedContent(
targetState = information,
label = "Information content",
) { information ->
val informationValue = getInformationValue(information)
if (informationValue == null) {
TextShimmer(
style = TangemTheme.typography2.captionMedium12,
text = "aaaaa",
)
} else {
Text(
text = informationValue,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
}
}
Row {
Text(
text = " $DOT ",
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance)
if (balanceValue == null) {
TextShimmer(
style = TangemTheme.typography2.captionMedium12,
text = "aaaaa",
)
} else {
Text(
text = balanceValue,
style = TangemTheme.typography2.captionMedium12.applyBladeBrush(
isEnabled = isFlickering,
textColor = TangemTheme.colors2.text.neutral.secondary,
),
maxLines = 1,
)
}
}
}
}
@Composable
private fun WalletNameRow(model: PortfolioSelectorItemUM.GroupTitle, modifier: Modifier = Modifier) {
Row(
modifier = modifier.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Text(
modifier = Modifier.align(Alignment.CenterVertically),
text = model.name.resolveReference(),
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_key_card_20),
modifier = Modifier
.align(Alignment.Bottom)
.size(TangemTheme.dimens2.x5),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::class) params: PortfolioSelectorUM) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
PortfolioSelectorContentV2(
state = params,
modifier = Modifier.background(color = TangemTheme.colors.background.tertiary),
)
}
}
}
}

View file

@ -0,0 +1,701 @@
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.RawMarketToken
import com.tangem.domain.markets.TokenMarketInfo
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: RawMarketToken = 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 accountToAdd and preferredNetwork is on account and addable WHEN resolve THEN return preferred`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val explicitAccount = 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 availableAccount()),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(BITCOIN, ETHEREUM),
selectedWallet = userWallet,
tokenParams = tokenParams,
accountToAdd = explicitAccount,
preferredNetwork = ETHEREUM,
)
Truth.assertThat(result?.network).isEqualTo(ETHEREUM)
}
@Test
fun `GIVEN accountToAdd and preferred is not on account WHEN resolve THEN use pickAddableNetwork`() = runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val explicitAccount = 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 availableAccount()),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM, BITCOIN),
selectedWallet = userWallet,
tokenParams = tokenParams,
accountToAdd = explicitAccount,
preferredNetwork = POLYGON,
)
Truth.assertThat(result?.network).isEqualTo(ETHEREUM)
}
@Test
fun `GIVEN accountToAdd and preferred on account has no derivation WHEN resolve THEN fall back to pickAddableNetwork`() =
runTest {
val walletId = UserWalletId(WALLET_ID_A)
val userWallet = userWallet(walletId)
val explicitAccount = 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 availableAccount()),
),
),
)
val result = resolver.resolve(
availableToAddData = data,
orderedNetworks = listOf(ETHEREUM, BITCOIN),
selectedWallet = userWallet,
tokenParams = tokenParams,
accountToAdd = explicitAccount,
preferredNetwork = ETHEREUM,
)
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,
)
val POLYGON = TokenMarketInfo.Network(
networkId = "polygon",
isExchangeable = true,
contractAddress = null,
decimalCount = 18,
)
}
}