Updated on 2026-08-14
This commit is contained in:
commit
d036b23ba4
1274 changed files with 51752 additions and 15452 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue