Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 deletions

View file

@ -10,11 +10,6 @@ plugins {
android {
namespace = "com.tangem.features.commonfeatures.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.commonFeatures.api)
@ -86,7 +81,6 @@ dependencies {
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)

View file

@ -1,93 +1,232 @@
package com.tangem.features.commonfeatures.impl.addfunds
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
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.extensions.resourceReference
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
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.core.ui.res.TangemThemeRedesign
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addfunds.model.AddFundsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addfunds.model.uiSpec
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.core.ui.R as CoreR
@Suppress("LongParameterList")
internal class DefaultAddFundsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: AddFundsComponent.Params,
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
userPortfolioComponentFactory: UserPortfolioComponent.Factory,
walletFeatureToggles: WalletFeatureToggles,
) : AppComponentContext by appComponentContext, AddFundsComponent {
private val model: AddFundsModel = getOrCreateModel(params)
private val chooseTokenComponent: ChooseTokenComponent = chooseTokenComponentFactory.create(
context = child(key = "addFundsChooseToken"),
params = ChooseTokenComponent.Params(bridge = model.chooseTokenBridge),
)
private val isCompactTokenActions: Boolean = params.launchMode is AddFundsComponent.LaunchMode.TokenActionsOnly
private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create(
context = child(key = "addFundsTokenActions"),
params = TokenActionsComponent.Params(
data = model.tokenActionsData,
callbacks = model,
bottomAction = TokenActionsComponent.BottomAction.GoToToken,
isRedesignForced = true,
),
)
private val isAddFundsStage1Enabled: Boolean = walletFeatureToggles.isAddFundsStage1Enabled
private val tokenActionsComponent: TokenActionsComponent by lazy {
tokenActionsComponentFactory.create(
context = child(key = "addFundsTokenActions"),
params = TokenActionsComponent.Params(
data = model.tokenActionsData,
callbacks = model,
bottomAction = model.currentBottomAction,
isRedesignForced = true,
isCompact = isCompactTokenActions,
),
)
}
private val chooseTokenComponent: ChooseTokenComponent? by lazy {
(params.launchMode as? AddFundsComponent.LaunchMode.ChooseToken)?.let {
chooseTokenComponentFactory.create(
context = child(key = "addFundsChooseToken"),
params = ChooseTokenComponent.Params(
bridge = model.chooseTokenBridge,
),
)
}
}
private val userPortfolioComponent: UserPortfolioComponent by lazy {
userPortfolioComponentFactory.create(
context = child(key = "addFundsUserPortfolio"),
params = UserPortfolioComponent.Params(
uiState = model.userPortfolioStateController.uiState,
callbacks = object : UserPortfolioComponent.Callbacks {
override fun onContinueFromUserPortfolio() = Unit
},
),
)
}
override fun dismiss() = model.onDismiss()
@Composable
override fun Content(modifier: Modifier) {
chooseTokenComponent.Content(modifier)
val isTokenActionsShown by model.isTokenActionsShown.collectAsStateWithLifecycle()
if (isTokenActionsShown) {
// force use redesign theme here according to the task requirements, will be reworked in the next release
TangemThemeRedesign {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = model::onTokenActionsDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors2.surface.level2,
scrollableContent = true,
title = {
TangemModalBottomSheetTitle(
modifier = Modifier.fillMaxWidth(),
title = resourceReference(R.string.common_get_token),
endIconRes = R.drawable.ic_close_24,
onEndClick = model::onTokenActionsDismiss,
)
},
content = { _ ->
Column(
modifier = Modifier.padding(
start = TangemTheme.dimens2.x4,
top = TangemTheme.dimens2.x2,
end = TangemTheme.dimens2.x4,
bottom = TangemTheme.dimens2.x4,
),
) {
tokenActionsComponent.Content(Modifier)
}
},
)
}
override fun BottomSheet() {
val route by model.uiRoute.collectAsStateWithLifecycle()
val canGoBack by model.canGoBack.collectAsStateWithLifecycle()
LaunchedEffect(route) {
if (route != AddFundsModel.UiRoute.UserPortfolio) return@LaunchedEffect
val mode = params.launchMode as? AddFundsComponent.LaunchMode.FilteredByRawId ?: return@LaunchedEffect
model.userPortfolioStateController.updateAndWaitNotNullState(
allAvailableData = model.buildAvailableToAddDataForChooser(),
rawCurrencyId = mode.rawCurrencyId,
)
}
WithOptionalRedesignTheme(isEnabled = isAddFundsStage1Enabled) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
onBack = if (canGoBack) model::onBack else ::dismiss,
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = when (params.launchMode) {
is AddFundsComponent.LaunchMode.TokenActionsOnly -> TangemBottomSheetType.Modal
is AddFundsComponent.LaunchMode.ChooseToken -> TangemBottomSheetType.Default
is AddFundsComponent.LaunchMode.FilteredByRawId ->
if (route is AddFundsModel.UiRoute.TokenActions) {
TangemBottomSheetType.Default
} else {
TangemBottomSheetType.Modal
}
},
containerColor = TangemTheme.colors2.surface.level2,
title = {
AddFundsBottomSheetTitle(
route = route,
canGoBack = canGoBack,
onBackClick = model::onBack,
onCloseClick = ::dismiss,
)
},
content = {
val animatedContentModifier =
if (params.launchMode is AddFundsComponent.LaunchMode.ChooseToken) {
Modifier.fillMaxSize()
} else {
Modifier
}
AnimatedContent(
targetState = route,
modifier = animatedContentModifier,
label = "AddFundsContentAnimation",
) { animatedRoute ->
AddFundsRouteContent(
route = animatedRoute,
shouldFillHeight = !isCompactTokenActions && animatedRoute.uiSpec().shouldFillHeight,
)
}
},
)
}
}
@Composable
private fun AddFundsRouteContent(route: AddFundsModel.UiRoute, shouldFillHeight: Boolean) {
val spec = route.uiSpec()
val horizontalPadding = if (spec.shouldApplyHorizontalPadding) {
Modifier.padding(horizontal = TangemTheme.dimens2.x4)
} else {
Modifier
}
val sizeModifier = if (shouldFillHeight) Modifier.fillMaxSize() else Modifier.fillMaxWidth()
RenderRoute(route, horizontalPadding.then(sizeModifier))
}
@Composable
private fun RenderRoute(route: AddFundsModel.UiRoute, modifier: Modifier = Modifier) {
when (route) {
AddFundsModel.UiRoute.Loading -> Unit
AddFundsModel.UiRoute.ChooseToken -> chooseTokenComponent?.Content(modifier)
AddFundsModel.UiRoute.UserPortfolio -> CompositionLocalProvider(
LocalTangemBottomSheetContentBottomInset provides TangemTheme.dimens2.x4,
) {
userPortfolioComponent.Content(modifier)
}
AddFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier)
}
}
@Composable
private fun AddFundsBottomSheetTitle(
route: AddFundsModel.UiRoute,
canGoBack: Boolean,
onBackClick: () -> Unit,
onCloseClick: () -> Unit,
) {
TangemTopBar(
title = route.uiSpec().title,
type = TangemTopBarType.BottomSheet,
startContent = if (canGoBack) {
{ CircleIconButton(iconRes = CoreR.drawable.ic_arrow_back_28, onClick = onBackClick) }
} else {
null
},
endContent = {
CircleIconButton(iconRes = R.drawable.ic_close_24, onClick = onCloseClick)
},
)
}
@Composable
private fun WithOptionalRedesignTheme(isEnabled: Boolean, content: @Composable () -> Unit) {
if (isEnabled) {
TangemThemeRedesign(content = content)
} else {
content()
}
}
@Composable
private fun CircleIconButton(iconRes: Int, onClick: () -> Unit) {
Icon(
imageVector = ImageVector.vectorResource(id = iconRes),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(onClick = onClick)
.padding(TangemTheme.dimens2.x2),
)
}
@AssistedFactory

View file

@ -19,8 +19,6 @@ internal sealed class AddFundsAnalyticsEvent(
class ButtonReceive : AddFundsAnalyticsEvent(event = "Button - Receive")
class ButtonGoToToken : AddFundsAnalyticsEvent(event = "Button - Go to Token")
companion object {
private const val CATEGORY = "Add Funds"
const val SOURCE_MAIN_SCREEN = "Main Screen"

View file

@ -1,5 +1,6 @@
package com.tangem.features.commonfeatures.impl.addfunds.model
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.markets.action.CryptoCurrencyData
@ -8,14 +9,25 @@ 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.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
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.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData
import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.features.commonfeatures.impl.addfunds.analytics.AddFundsAnalyticsEvent
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.state.UserPortfolioStateController
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
@ -23,98 +35,257 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
internal class AddFundsModel @Inject constructor(
paramsContainer: ParamsContainer,
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
userPortfolioStateControllerFactory: UserPortfolioStateController.Factory,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2,
private val appRouter: AppRouter,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
private val appRouter: AppRouter,
override val dispatchers: CoroutineDispatcherProvider,
) : Model(), TokenActionsComponent.Callbacks {
private val params = paramsContainer.require<AddFundsComponent.Params>()
val launchMode: AddFundsComponent.LaunchMode = params.launchMode
val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(
modelScope = modelScope,
settings = ChooseTokenBridge.Settings.AddFunds,
analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.ScreensSources(SCREEN_SOURCE),
),
)
private val routeStack = MutableStateFlow(listOf<UiRoute>(UiRoute.Loading))
private val selectedToken = MutableStateFlow<ChooseTokenResult?>(null)
val uiRoute: StateFlow<UiRoute> = routeStack
.map { it.last() }
.distinctUntilChanged()
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = UiRoute.Loading)
val isTokenActionsShown: StateFlow<Boolean> = selectedToken
.map { it != null }
val canGoBack: StateFlow<Boolean> = routeStack
.map { it.size > 1 }
.distinctUntilChanged()
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = false)
private val tokenActionsTrigger = MutableStateFlow<TokenActionsRequest?>(null)
private val filteredEntries = MutableStateFlow<List<FilteredEntry>>(emptyList())
val currentBottomAction: MutableStateFlow<BottomAction> =
MutableStateFlow(BottomAction.None)
@OptIn(ExperimentalCoroutinesApi::class)
val tokenActionsData: Flow<CryptoCurrencyData> = selectedToken
val tokenActionsData: Flow<CryptoCurrencyData> = tokenActionsTrigger
.filterNotNull()
.flatMapLatest { result ->
val cryptoPortfolio = result.account as? AccountStatus.CryptoPortfolio
?: return@flatMapLatest emptyFlow()
getCryptoCurrencyActionsUseCase(
accountId = cryptoPortfolio.account.accountId,
currency = result.currency.currency,
).map { actionsState ->
.flatMapLatest { request ->
combine(
getCryptoCurrencyActionsUseCase(
accountId = request.account.account.accountId,
currency = request.status.currency,
),
isAccountsModeEnabledUseCase(),
) { actionsState, isAccountMode ->
CryptoCurrencyData(
userWallet = result.wallet,
userWallet = request.userWallet,
status = actionsState.cryptoCurrencyStatus,
actions = actionsState.states,
isAccountMode = false,
account = cryptoPortfolio,
isAccountMode = isAccountMode,
account = request.account,
)
}
}
val chooseTokenBridge: ChooseTokenBridge by lazy {
chooseTokenBridgeFactory.create(
modelScope = modelScope,
settings = ChooseTokenBridge.Settings.AddFunds,
analyticsPayload = setOf(ChooseTokenAnalyticsPayload.ScreensSources(SCREEN_SOURCE)),
)
}
val userPortfolioStateController: UserPortfolioStateController = userPortfolioStateControllerFactory.create(
modelScope = modelScope,
onTokenSelected = { result ->
openTokenActions(
request = TokenActionsRequest(
userWallet = result.wallet,
account = result.account,
status = result.addedCurrency,
),
bottomAction = BottomAction.None,
)
},
)
init {
chooseTokenBridge.selectWalletTab(params.userWalletId)
analyticsEventHandler.send(
AddFundsAnalyticsEvent.MethodScreenOpened(source = AddFundsAnalyticsEvent.SOURCE_MAIN_SCREEN),
)
observeBridge()
when (val mode = launchMode) {
is AddFundsComponent.LaunchMode.ChooseToken -> initChooseToken(mode)
is AddFundsComponent.LaunchMode.TokenActionsOnly -> initTokenActionsOnly(mode)
is AddFundsComponent.LaunchMode.FilteredByRawId -> initFilteredByRawId(mode)
}
}
override fun onBottomActionClick() {
val result = selectedToken.value ?: return
selectedToken.value = null
analyticsEventHandler.send(AddFundsAnalyticsEvent.ButtonGoToToken())
appRouter.replaceCurrent(
AppRoute.CurrencyDetails(
userWalletId = result.wallet.walletId,
currency = result.currency.currency,
),
)
fun onBack() {
routeStack.update { stack -> if (stack.size > 1) stack.dropLast(1) else stack }
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action) {
fun onDismiss() = params.onDismiss()
override fun onBottomActionClick(bottomAction: BottomAction) {
val request = tokenActionsTrigger.value
if (bottomAction == BottomAction.GoToToken && request != null) {
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = request.userWallet.walletId,
currency = request.status.currency,
),
)
}
params.onDismiss()
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {
val event = when (action) {
TokenActionsBSContentUM.Action.Buy -> AddFundsAnalyticsEvent.ButtonBuy()
TokenActionsBSContentUM.Action.Exchange -> AddFundsAnalyticsEvent.ButtonSwap()
TokenActionsBSContentUM.Action.Receive -> AddFundsAnalyticsEvent.ButtonReceive()
else -> return
else -> null
}
event?.let { analyticsEventHandler.send(it) }
if (shouldDismiss) {
params.onDismiss()
}
analyticsEventHandler.send(event)
}
fun onTokenActionsDismiss() {
selectedToken.value = null
fun buildAvailableToAddDataForChooser(): AvailableToAddData {
val byWallet = filteredEntries.value.groupBy { it.userWallet.walletId }
return AvailableToAddData(
availableToAddWallets = byWallet.mapValues { (_, entries) ->
AvailableToAddWallet(
userWallet = entries.first().userWallet,
accounts = entries.map { it.account }.distinct(),
availableNetworks = emptySet(),
availableToAddAccounts = emptyMap(),
)
},
)
}
private fun observeBridge() {
private fun initChooseToken(mode: AddFundsComponent.LaunchMode.ChooseToken) {
chooseTokenBridge.selectWalletTab(mode.userWalletId)
analyticsEventHandler.send(
AddFundsAnalyticsEvent.MethodScreenOpened(source = AddFundsAnalyticsEvent.SOURCE_MAIN_SCREEN),
)
replaceRoot(UiRoute.ChooseToken)
modelScope.launch {
chooseTokenBridge.onCurrencyChosen.receiveAsFlow().collect { result ->
selectedToken.value = result
}
chooseTokenBridge.onCurrencyChosen.receiveAsFlow().collect(::openTokenActionsFromBridge)
}
modelScope.launch {
chooseTokenBridge.onClose.receiveAsFlow().collect {
appRouter.pop()
chooseTokenBridge.onClose.receiveAsFlow().collect { params.onDismiss() }
}
}
private fun initTokenActionsOnly(mode: AddFundsComponent.LaunchMode.TokenActionsOnly) {
modelScope.launch {
val wallet = getUserWalletUseCase.invokeFlow(mode.userWalletId)
.mapNotNull { it.getOrNull() }
.first()
val match = multiAccountStatusListSupplier()
.first()
.firstOrNull { it.userWalletId == mode.userWalletId }
?.accountStatuses
?.filterCryptoPortfolio()
?.firstNotNullOfOrNull { accountStatus ->
accountStatus.tokenList.flattenCurrencies()
.firstOrNull { it.currency.id == mode.currency.id }
?.let { accountStatus to it }
}
?: run {
params.onDismiss()
return@launch
}
tokenActionsTrigger.value = TokenActionsRequest(wallet, match.first, match.second)
replaceRoot(UiRoute.TokenActions)
}
}
private fun initFilteredByRawId(mode: AddFundsComponent.LaunchMode.FilteredByRawId) {
modelScope.launch {
val entries = collectFilteredEntries(mode.rawCurrencyId)
when (entries.size) {
0 -> params.onDismiss()
1 -> {
val entry = entries.first()
tokenActionsTrigger.value = TokenActionsRequest(entry.userWallet, entry.account, entry.status)
replaceRoot(UiRoute.TokenActions)
}
else -> {
filteredEntries.value = entries
replaceRoot(UiRoute.UserPortfolio)
}
}
}
}
private suspend fun collectFilteredEntries(rawCurrencyId: CryptoCurrency.RawID): List<FilteredEntry> {
val accountLists = multiAccountStatusListSupplier().first()
return accountLists.flatMap { accountStatusList ->
val wallet = getUserWalletUseCase.invokeFlow(accountStatusList.userWalletId)
.mapNotNull { it.getOrNull() }
.firstOrNull()
?: return@flatMap emptyList()
accountStatusList.accountStatuses.filterCryptoPortfolio().flatMap { accountStatus ->
accountStatus.tokenList.flattenCurrencies()
.filter { status ->
val id = status.currency.id.rawCurrencyId ?: return@filter false
getTokenIdIfL2Network(id.value) == rawCurrencyId.value
}
.map { status -> FilteredEntry(wallet, accountStatus, status) }
}
}
}
private fun openTokenActionsFromBridge(result: ChooseTokenResult) {
val account = result.account as? AccountStatus.CryptoPortfolio ?: return
openTokenActions(
request = TokenActionsRequest(result.wallet, account, result.currency),
bottomAction = if (result.wasJustAdded) {
BottomAction.GoToToken
} else {
BottomAction.None
},
)
}
private fun openTokenActions(request: TokenActionsRequest, bottomAction: BottomAction) {
tokenActionsTrigger.value = request
currentBottomAction.value = bottomAction
pushRoute(UiRoute.TokenActions)
}
private fun replaceRoot(route: UiRoute) {
routeStack.value = listOf(route)
}
private fun pushRoute(route: UiRoute) {
routeStack.update { it + route }
}
sealed interface UiRoute {
data object Loading : UiRoute
data object ChooseToken : UiRoute
data object UserPortfolio : UiRoute
data object TokenActions : UiRoute
}
private data class TokenActionsRequest(
val userWallet: UserWallet,
val account: AccountStatus.CryptoPortfolio,
val status: CryptoCurrencyStatus,
)
private data class FilteredEntry(
val userWallet: UserWallet,
val account: AccountStatus.CryptoPortfolio,
val status: CryptoCurrencyStatus,
)
private companion object {
const val SCREEN_SOURCE = "AddFunds"
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.commonfeatures.impl.addfunds.model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.commonfeatures.impl.R
import com.tangem.core.ui.R as CoreR
internal data class AddFundsRouteUiSpec(
val title: TextReference,
val shouldApplyHorizontalPadding: Boolean,
val shouldFillHeight: Boolean,
)
internal fun AddFundsModel.UiRoute.uiSpec(): AddFundsRouteUiSpec = when (this) {
AddFundsModel.UiRoute.Loading -> AddFundsRouteUiSpec(
title = resourceReference(R.string.common_add_funds),
shouldApplyHorizontalPadding = false,
shouldFillHeight = false,
)
AddFundsModel.UiRoute.ChooseToken -> AddFundsRouteUiSpec(
title = resourceReference(R.string.common_add_funds),
shouldApplyHorizontalPadding = false,
shouldFillHeight = true,
)
AddFundsModel.UiRoute.UserPortfolio -> AddFundsRouteUiSpec(
title = resourceReference(R.string.common_add_funds),
shouldApplyHorizontalPadding = false,
shouldFillHeight = false,
)
AddFundsModel.UiRoute.TokenActions -> AddFundsRouteUiSpec(
title = resourceReference(CoreR.string.common_get_token),
shouldApplyHorizontalPadding = true,
shouldFillHeight = true,
)
}

View file

@ -23,7 +23,7 @@ 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 com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
import dev.chrisbanes.haze.rememberHazeState
@Composable

View file

@ -6,7 +6,7 @@ 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
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
@Composable
internal fun AddToPortfolioBottomSheetSwitch(

View file

@ -22,7 +22,7 @@ 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
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
@Composable
internal fun AddToPortfolioBottomSheetV2(
@ -39,6 +39,11 @@ internal fun AddToPortfolioBottomSheetV2(
contentStack.value = stack
}
val type = if (stack.active.configuration is AddToPortfolioRoutes.TokenActions) {
TangemBottomSheetType.Default
} else {
TangemBottomSheetType.Modal
}
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
onBack = onBack,
config = TangemBottomSheetConfig(
@ -46,7 +51,7 @@ internal fun AddToPortfolioBottomSheetV2(
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
type = type,
containerColor = TangemTheme.colors2.surface.level2,
title = {
AddToPortfolioBottomSheetTitle(
@ -86,7 +91,9 @@ private fun AddToPortfolioRouteContent(animatedStack: ChildStack<AddToPortfolioR
Spacer(modifier = Modifier.height(scrollBottomReserve))
}
} else {
animatedStack.active.instance.Content(modifier = baseModifier)
val isFullScreenRoute = animatedStack.active.configuration is AddToPortfolioRoutes.TokenActions
val sizeModifier = if (isFullScreenRoute) Modifier.fillMaxSize() else Modifier
animatedStack.active.instance.Content(modifier = baseModifier.then(sizeModifier))
}
}

View file

@ -15,11 +15,14 @@ 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.api.tokenactions.BottomAction
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes
import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.flowOf
@Suppress("LongParameterList")
internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
@ -60,6 +63,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
params = TokenActionsComponent.Params(
callbacks = model,
data = model.tokenActionsData,
bottomAction = flowOf(BottomAction.GoToToken),
),
)
}

View file

@ -92,13 +92,5 @@ internal class PortfolioAnalyticsEvent(
if (source != null) put("Source", source)
},
)
fun getTokenLater() = PortfolioAnalyticsEvent(
event = "Popup Get token - Button Later",
category = category,
params = buildMap {
if (source != null) put("Source", source)
},
)
}
}

View file

@ -4,8 +4,8 @@ import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioCompo
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 com.tangem.features.commonfeatures.impl.userportfolio.DefaultUserPortfolioComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn

View file

@ -5,8 +5,8 @@ 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 com.tangem.features.commonfeatures.impl.tokenactions.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn

View file

@ -29,18 +29,20 @@ 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.api.tokenactions.BottomAction
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.tokenactions.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.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.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.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -107,10 +109,6 @@ internal class AddToPortfolioModel @Inject constructor(
startRedesignAddToPortfolioFlow()
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action) {
analyticsEventHandler.send(eventBuilder.getTokenActionClick(actionUM = action))
}
private fun <T> replayMutableSharedFlow() = MutableSharedFlow<T>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
@ -155,7 +153,7 @@ internal class AddToPortfolioModel @Inject constructor(
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun startRedesignAddToPortfolioFlow() {
channelFlow<Unit> {
fun finishSuccessFlow(result: AddToPortfolioManager.Result) {
@ -312,9 +310,15 @@ internal class AddToPortfolioModel @Inject constructor(
.onEmpty { finishSuccessFlow(result) }
.launchIn(this)
callbackDelegate.onChooseTokenBottomActionClick.receiveAsFlow().first()
analyticsEventHandler.send(eventBuilder.getTokenLater())
finishSuccessFlow(result)
when (val meta = terminalTokenActionsFlow().first()) {
is AddToPortfolioManager.FinishMeta.OnBottomAction -> {
finishSuccessFlow(result.copy(meta = meta))
}
AddToPortfolioManager.FinishMeta.OnQuickAction -> {
finishSuccessFlow(result.copy(meta = meta))
}
AddToPortfolioManager.FinishMeta.None -> Unit
}
}
.catch { throwable ->
TangemLogger.e("Error", throwable)
@ -323,6 +327,21 @@ internal class AddToPortfolioModel @Inject constructor(
.launchIn(modelScope)
}
private fun terminalTokenActionsFlow() = channelFlow {
callbackDelegate.onChooseTokenBottomActionClick.receiveAsFlow()
.onEach { bottomAction ->
channel.send(AddToPortfolioManager.FinishMeta.OnBottomAction(bottomAction))
}
.launchIn(this)
callbackDelegate.onQuickActionClick.receiveAsFlow()
.onEach { (action, shouldDismiss) ->
analyticsEventHandler.send(eventBuilder.getTokenActionClick(actionUM = action))
if (shouldDismiss) channel.send(AddToPortfolioManager.FinishMeta.OnQuickAction)
}
.launchIn(this)
awaitClose()
}
private suspend fun getInitialSelection(
initialData: AvailableToAddData,
): AddToPortfolioInitialSelectionResolver.InitialSelection? {
@ -529,7 +548,8 @@ internal class AddToPortfolioCallbackDelegate @Inject constructor() :
UserPortfolioComponent.Callbacks {
val onNetworkSelected = Channel<TokenMarketInfo.Network>()
val onChooseTokenBottomActionClick = Channel<Unit>()
val onChooseTokenBottomActionClick = Channel<BottomAction>()
val onQuickActionClick = Channel<Pair<TokenActionsBSContentUM.Action, Boolean>>()
val onChangeNetworkClick = Channel<Unit>()
val onChangePortfolioClick = Channel<Unit>()
val onTokenAdded = Channel<CryptoCurrencyStatus>()
@ -539,8 +559,12 @@ internal class AddToPortfolioCallbackDelegate @Inject constructor() :
onNetworkSelected.trySend(network)
}
override fun onBottomActionClick() {
onChooseTokenBottomActionClick.trySend(Unit)
override fun onBottomActionClick(bottomAction: BottomAction) {
onChooseTokenBottomActionClick.trySend(bottomAction)
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {
onQuickActionClick.trySend(action to shouldDismiss)
}
override fun onChangeNetworkClick() {

View file

@ -44,7 +44,7 @@ internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (th
)
AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.common_get_token),
isScrollable = true,
isScrollable = false,
shouldApplyHorizontalPadding = true,
footer = AddToPortfolioFooterKind.None,
)

View file

@ -2,6 +2,7 @@ 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.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
@ -9,9 +10,9 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge.Sett
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.ChooseTokenModel
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
@ -32,7 +33,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
private val onSearchQuery: Channel<SearchQuery> = Channel()
override val searchQueryState: StateFlow<SearchQuery> = onSearchQuery.receiveAsFlow()
.debounce(ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY)
.debounce(ChooseTokenModel.DEBOUNCE_SEARCH_DELAY)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = SearchQuery.Empty)
private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create(
@ -45,6 +46,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
modelScope = modelScope,
searchQueryState = searchQueryState,
portfolioListBlockDelegate = portfolioListBlockDelegate,
featureSettings = settings,
)
override val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>
@ -53,6 +55,9 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor(
override val fullPortfolioBlock: StateFlow<ChooseTokenPortfolioFullBlockUM?>
get() = portfolioFullBlockDelegate.fullPortfolioBlock
override val selectedWalletFlow: SharedFlow<UserWallet>
get() = portfolioFullBlockDelegate.selectedWalletFlow
init {
portfolioListBlockDelegate.onTokenChosen.receiveAsFlow()
.onEach { chooseResult -> onCurrencyChosen(chooseResult) }

View file

@ -41,7 +41,7 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor(
override fun Content(modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by model.state.collectAsStateWithLifecycle()
ChooseTokenScreen(state = state)
ChooseTokenScreen(state = state, modifier = modifier)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -87,6 +87,7 @@ internal class ChooseTokenListItemConverter(
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.toPortfolioItem(params)
is AccountStatus.Payment -> accountStatus.createPaymentAccountItem(params.expandedAccounts)
is AccountStatus.Virtual -> null
}
}
.filter { portfolio -> portfolio.tokens.isNotEmpty() }

View file

@ -6,6 +6,8 @@ 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.R
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
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
@ -14,10 +16,9 @@ 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.market.state.SwapMarketState
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
@ -41,6 +42,8 @@ internal class ChooseTokenModel @Inject constructor(
screensSourcesName = bridge.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty(),
selectedWalletFlow = bridge.selectedWalletFlow,
shouldShowSingleCurrencyWallets = bridge.settings.isShowSingleCurrencyWallets,
)
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot
@ -78,18 +81,10 @@ internal class ChooseTokenModel @Inject constructor(
.onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { addedResult ->
val isSearched = ChooseTokenAnalyticsPayload.IsSearched(isSearchingState)
val isMarketToken = ChooseTokenAnalyticsPayload.IsMarketTokenSelected(true)
val chooseTokenResult = ChooseTokenResult(
currency = addedResult.addedCurrency,
account = addedResult.account,
wallet = addedResult.wallet,
analyticsPayload = setOf(isSearched, isMarketToken),
)
bridge.onCurrencyChosen(chooseTokenResult)
marketBlockDelegate.addToPortfolioSlot.dismiss()
}
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = true) }
.launchIn(modelScope)
addToPortfolioManager.onAddedTokenClick.receiveAsFlow()
.onEach { notifyCurrencyChosen(it, isMarketTokenSelected = false) }
.launchIn(modelScope)
}
@ -97,6 +92,20 @@ internal class ChooseTokenModel @Inject constructor(
bridge.onClose()
}
private fun notifyCurrencyChosen(addedResult: AddToPortfolioManager.Result, isMarketTokenSelected: Boolean) {
val chooseTokenResult = ChooseTokenResult(
currency = addedResult.addedCurrency,
account = addedResult.account,
wallet = addedResult.wallet,
analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.IsSearched(isSearchingState),
ChooseTokenAnalyticsPayload.IsMarketTokenSelected(isMarketTokenSelected),
),
)
bridge.onCurrencyChosen(chooseTokenResult)
marketBlockDelegate.addToPortfolioSlot.dismiss()
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
@ -112,6 +121,7 @@ internal class ChooseTokenModel @Inject constructor(
private fun getInitState() = ChooseTokenInitialUM(
screenTitle = bridge.settings.title,
isAppBarShown = bridge.settings.isAppBarShown,
onCloseClick = ::onBackClicked,
searchBar = getInitialSearchBar(),
)

View file

@ -6,7 +6,10 @@ 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.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
@ -14,9 +17,9 @@ 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.addtoportfolio.AddToPortfolioManager
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
@ -25,11 +28,9 @@ import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.toImmutableList
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(
@ -37,9 +38,12 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val screensSourcesName: String,
@Assisted private val selectedWalletFlow: SharedFlow<UserWallet>,
@Assisted private val shouldShowSingleCurrencyWallets: Boolean,
) {
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
@ -52,7 +56,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
)
val marketsStateFlow: Flow<SwapMarketState> = searchQueryState
private val baseMarketsStateFlow: Flow<SwapMarketState> = searchQueryState
// Switch between default and search market flows
.map { it.value.isEmpty() }
.distinctUntilChanged()
@ -66,6 +70,24 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
}
/**
* Market block constrained by the currently selected wallet:
* - single-currency wallet: hidden entirely (`null`) - no market tokens can be added;
* - single-currency-with-token wallet (e.g. NODL): items filtered to the wallet's network,
* block hidden when nothing remains;
* - multi-currency wallet: shown as is.
*
* When single-currency wallets aren't selectable here (e.g. swap), the wallet is always
* multi-currency, so we skip the per-wallet logic entirely and return [baseMarketsStateFlow].
*/
val marketsStateFlow: Flow<SwapMarketState?> = if (!shouldShowSingleCurrencyWallets) {
baseMarketsStateFlow
} else {
selectedWalletFlow
.flatMapLatest(::marketsFlowForWallet)
.distinctUntilChanged()
}
private val defaultMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
@ -181,6 +203,44 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
}
private fun marketsFlowForWallet(wallet: UserWallet): Flow<SwapMarketState?> {
if (wallet !is UserWallet.Cold) return baseMarketsStateFlow
val resolver = wallet.scanResponse.cardTypesResolver
return when {
// Single-currency wallet can't hold market tokens - hide the whole block.
resolver.isSingleWallet() -> flowOf(null)
// Single-currency-with-token wallet (NODL) - keep only tokens available on the wallet's network(s).
resolver.isSingleWalletWithToken() -> combine(
baseMarketsStateFlow,
singleAccountStatusListSupplier(wallet.walletId),
) { state, accountStatusList ->
filterStateByNetwork(state, accountStatusList.allowedNetworkIds())
}
// Multi-currency wallet - the common case, no filtering needed.
else -> baseMarketsStateFlow
}
}
private fun AccountStatusList.allowedNetworkIds(): Set<String> =
flattenCurrencies().mapTo(hashSetOf()) { it.currency.network.rawId }
private fun filterStateByNetwork(state: SwapMarketState, allowedNetworkIds: Set<String>): SwapMarketState? {
if (state !is SwapMarketState.Content) return state
if (allowedNetworkIds.isEmpty()) return null
val filteredItems = state.items.filter { item ->
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id)
tokenMarket?.networks?.any { allowedNetworkIds.contains(it.networkId) } == true
}.toImmutableList()
return if (filteredItems.isEmpty()) {
null
} else {
state.copy(items = filteredItems, total = filteredItems.size)
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id) ?: return
@ -218,6 +278,8 @@ internal class MarketBlockDelegate @AssistedInject constructor(
searchQueryState: StateFlow<SearchQuery>,
modelScope: CoroutineScope,
screensSourcesName: String,
selectedWalletFlow: SharedFlow<UserWallet>,
shouldShowSingleCurrencyWallets: Boolean,
): MarketBlockDelegate
}
}

View file

@ -4,11 +4,11 @@ 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.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.model.ChooseTokenPortfolioFullBlockUM
@ -35,9 +35,11 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
@Assisted private val modelScope: CoroutineScope,
@Assisted private val portfolioListBlockDelegate: PortfolioListBlockDelegate,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val featureSettings: ChooseTokenBridge.Settings,
) {
private val isSearchingState: Boolean get() = searchQueryState.isSearchingState
private val isOnlyMultiCurrency: Boolean get() = !featureSettings.isShowSingleCurrencyWallets
private val onWalletSelected = Channel<UserWalletId>(capacity = Channel.BUFFERED)
val selectedWalletFlow: SharedFlow<UserWallet> = onWalletSelected.receiveAsFlow()
@ -51,9 +53,11 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
init {
val globalSelectedWallet = selectedWalletUseCase.sync().getOrNull()
val allWallets = getWalletsUseCase.invokeSync().filter { it.isMultiCurrency }
val allWallets = getWalletsUseCase.invokeSync()
.filter { !isOnlyMultiCurrency || it.isMultiCurrency }
val firstSelectedWallet = when {
globalSelectedWallet?.isMultiCurrency == true -> globalSelectedWallet
globalSelectedWallet != null && (!isOnlyMultiCurrency || globalSelectedWallet.isMultiCurrency) ->
globalSelectedWallet
allWallets.isNotEmpty() -> allWallets.first()
else -> null
}
@ -61,8 +65,10 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
}
private fun buildFlow() = flow {
val walletsFlow = getWalletsUseCase.invokeAsMap()
.map { wallets -> wallets.filterNot { (_, wallet) -> wallet.isLocked } }
val walletsFlow = getWalletsUseCase.invokeAsMap(
isOnlyMultiCurrency = isOnlyMultiCurrency,
filterLocked = true,
)
val fullPortfolioBlockFlow = combine(
flow = walletsFlow,
flow2 = portfolioListBlockDelegate.portfolioList,
@ -109,6 +115,7 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
modelScope: CoroutineScope,
portfolioListBlockDelegate: PortfolioListBlockDelegate,
searchQueryState: StateFlow<SearchQuery>,
featureSettings: ChooseTokenBridge.Settings,
): PortfolioFullBlockDelegate
}
}

View file

@ -39,6 +39,7 @@ 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.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
@ -82,12 +83,20 @@ private val ChooseTokenFullUM.isEmptyState: Boolean
internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.background(
color = if (LocalRedesignEnabled.current) {
TangemTheme.colors2.surface.level2
} else {
TangemTheme.colors.background.secondary
},
)
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBar(title = state.initialUM.screenTitle, onBackClick = state.initialUM.onCloseClick, Modifier)
if (state.initialUM.isAppBarShown) {
AppBar(title = state.initialUM.screenTitle, onBackClick = state.initialUM.onCloseClick, Modifier)
}
Content(
state = state,
@ -465,6 +474,7 @@ private val wallets
private val initialUM = ChooseTokenInitialUM(
screenTitle = stringReference("Choose token"),
isAppBarShown = true,
onCloseClick = {},
searchBar = searchBar,
)

View file

@ -13,6 +13,7 @@ internal data class ChooseTokenFullUM(
internal data class ChooseTokenInitialUM(
val screenTitle: TextReference,
val isAppBarShown: Boolean,
val onCloseClick: () -> Unit,
val searchBar: SearchBarUM,
)

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio
package com.tangem.features.commonfeatures.impl.tokenactions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -18,14 +18,16 @@ 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.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContentV2
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.features.commonfeatures.impl.tokenactions.model.TokenActionsModel
import com.tangem.features.commonfeatures.impl.tokenactions.ui.TokenActionsContent
import com.tangem.features.commonfeatures.impl.tokenactions.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
import kotlinx.coroutines.flow.flowOf
internal class TokenActionsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@ -74,15 +76,14 @@ internal class TokenActionsComponent @AssistedInject constructor(
data class Params(
val data: Flow<CryptoCurrencyData>,
val callbacks: Callbacks,
val bottomAction: BottomAction = BottomAction.Later,
val bottomAction: Flow<BottomAction> = flowOf(BottomAction.None),
val isRedesignForced: Boolean = false,
val isCompact: Boolean = false,
)
enum class BottomAction { Later, GoToToken }
interface Callbacks {
fun onBottomActionClick()
fun onQuickActionClick(action: TokenActionsBSContentUM.Action) {}
fun onBottomActionClick(bottomAction: BottomAction)
fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {}
}
@AssistedFactory

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
package com.tangem.features.commonfeatures.impl.tokenactions.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
@ -12,8 +12,8 @@ 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.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -45,7 +45,10 @@ internal class TokenActionsModel @Inject constructor(
private val tokenActionsHandler: TokenActionsHandler =
tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) },
onHandleQuickAction = { handledAction, shouldDismiss ->
handledQuickAction(handledAction, shouldDismiss)
},
coroutineScope = modelScope,
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()
@ -55,15 +58,17 @@ internal class TokenActionsModel @Inject constructor(
combine(
params.data,
getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { cryptoCurrencyData, isBalanceHidden ->
cryptoCurrencyData to isBalanceHidden
params.bottomAction,
) { cryptoCurrencyData, isBalanceHidden, bottomAction ->
Triple(cryptoCurrencyData, isBalanceHidden, bottomAction)
}
.mapLatest { (cryptoCurrencyData, isBalanceHidden) ->
.mapLatest { (cryptoCurrencyData, isBalanceHidden, bottomAction) ->
uiBuilder.build(
cryptoCurrencyData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
appCurrency = currentAppCurrency.value,
isBalanceHidden = isBalanceHidden,
bottomAction = bottomAction,
)
}
.flowOn(dispatchers.default)
@ -73,16 +78,18 @@ internal class TokenActionsModel @Inject constructor(
initialValue = null,
)
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch {
params.callbacks.onQuickActionClick(handledAction.action)
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (!isReceive) return@launch
val tokenConfig = withContext(dispatchers.default) {
receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
)
} ?: return@launch
bottomSheetNavigation.activate(tokenConfig)
}
private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction, shouldDismiss: Boolean) =
modelScope.launch {
val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive
if (isReceive) {
val tokenConfig = withContext(dispatchers.default) {
receiveAddressesFactory.create(
status = handledAction.cryptoCurrencyData.status,
userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId,
)
}
if (tokenConfig != null) bottomSheetNavigation.activate(tokenConfig)
}
params.callbacks.onQuickActionClick(handledAction.action, shouldDismiss)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.model
package com.tangem.features.commonfeatures.impl.tokenactions.model
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getTotalCryptoAmount
@ -11,6 +11,7 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI
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.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
@ -30,9 +31,9 @@ import com.tangem.features.commonfeatures.impl.R
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import java.math.BigDecimal
import javax.inject.Inject
@ -50,6 +51,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler: TokenActionsHandler,
appCurrency: AppCurrency,
isBalanceHidden: Boolean,
bottomAction: BottomAction,
): TokenActionsUM {
return if (designFeatureToggles.isRedesignEnabled || params.isRedesignForced) {
buildV2(
@ -57,11 +59,13 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler = tokenActionsHandler,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
bottomAction = bottomAction,
)
} else {
buildV1(
cryptoCurrencyData = cryptoCurrencyData,
tokenActionsHandler = tokenActionsHandler,
bottomAction = bottomAction,
)
}
}
@ -69,6 +73,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
private fun buildV1(
cryptoCurrencyData: CryptoCurrencyData,
tokenActionsHandler: TokenActionsHandler,
bottomAction: BottomAction,
): TokenActionsUM {
val status = cryptoCurrencyData.status
val tokenUM = TokenItemState.Content(
@ -88,9 +93,9 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler = tokenActionsHandler,
isRedesignEnabled = false,
),
bottomActionText = bottomActionText(params.bottomAction),
bottomActionText = bottomActionText(bottomAction),
onBottomActionClick = {
params.callbacks.onBottomActionClick()
params.callbacks.onBottomActionClick(bottomAction)
},
)
}
@ -100,6 +105,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler: TokenActionsHandler,
appCurrency: AppCurrency,
isBalanceHidden: Boolean,
bottomAction: BottomAction,
): TokenActionsUM {
val status = cryptoCurrencyData.status
val tokenUM = TokenItemState.Content(
@ -119,19 +125,20 @@ internal class TokenActionsUiBuilder @Inject constructor(
tokenActionsHandler = tokenActionsHandler,
isRedesignEnabled = true,
),
bottomActionText = bottomActionText(params.bottomAction),
bottomActionText = bottomActionText(bottomAction),
onBottomActionClick = {
params.callbacks.onBottomActionClick()
params.callbacks.onBottomActionClick(bottomAction)
},
isBalancesHidden = isBalanceHidden,
portfolioBadge = createPortfolioBadge(cryptoCurrencyData = cryptoCurrencyData),
isCompact = params.isCompact,
)
}
private fun bottomActionText(action: TokenActionsComponent.BottomAction): TextReference {
private fun bottomActionText(action: BottomAction): TextReference? {
return when (action) {
TokenActionsComponent.BottomAction.Later -> resourceReference(R.string.common_later)
TokenActionsComponent.BottomAction.GoToToken -> resourceReference(R.string.common_go_to_token)
BottomAction.GoToToken -> resourceReference(R.string.common_go_to_token)
BottomAction.None -> null
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
package com.tangem.features.commonfeatures.impl.tokenactions.ui
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
@ -39,7 +39,7 @@ 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 com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@ -73,13 +73,15 @@ internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Mod
}
}
SpacerH16()
if (state.bottomActionText != null) {
SpacerH16()
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = state.bottomActionText.resolveReference(),
onClick = state.onBottomActionClick,
)
SecondaryButton(
modifier = Modifier.fillMaxWidth(),
text = state.bottomActionText.resolveReference(),
onClick = state.onBottomActionClick,
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui
package com.tangem.features.commonfeatures.impl.tokenactions.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
@ -43,8 +43,8 @@ import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.core.ui.res.*
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@ -52,53 +52,84 @@ import java.util.UUID
@Composable
internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = Modifier) {
if (state.isCompact) {
CompactLayout(state, modifier)
} else {
FullLayout(state, modifier)
}
}
@Composable
private fun CompactLayout(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
QuickActionsList(state)
SpacerH(TangemTheme.dimens2.x4)
}
}
@Composable
private fun FullLayout(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
TokenHeader(
addedToken = state.token,
portfolioBadge = state.portfolioBadge,
isBalanceHidden = state.isBalancesHidden,
)
SpacerH(TangemTheme.dimens2.x2)
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
state.quickActions.actions.fastForEach { actionUM ->
key(actionUM.title) {
val transitionState = remember {
MutableTransitionState(initialState = false).apply { targetState = true }
}
AnimatedVisibility(
visibleState = transitionState,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
TokenActionRow(
iconRes = actionUM.icon,
title = actionUM.title,
description = actionUM.description,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }
.takeIf { actionUM.isLongClickAvailable },
)
}
}
TokenHeader(
addedToken = state.token,
portfolioBadge = state.portfolioBadge,
isBalanceHidden = state.isBalancesHidden,
)
}
QuickActionsList(state)
val bottomText = state.bottomActionText
if (bottomText != null) {
SpacerH(TangemTheme.dimens2.x6)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = state.onBottomActionClick,
text = bottomText,
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
}
}
SpacerH(TangemTheme.dimens2.x4)
}
}
SpacerH(TangemTheme.dimens2.x6)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = state.onBottomActionClick,
text = state.bottomActionText,
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
@Composable
private fun QuickActionsList(state: TokenActionsUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
state.quickActions.actions.fastForEach { actionUM ->
key(actionUM.title) {
val transitionState = remember {
MutableTransitionState(initialState = false).apply { targetState = true }
}
AnimatedVisibility(
visibleState = transitionState,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
TokenActionRow(
iconRes = actionUM.icon,
title = actionUM.title,
description = actionUM.description,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }
.takeIf { actionUM.isLongClickAvailable },
)
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state
package com.tangem.features.commonfeatures.impl.tokenactions.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.action.QuickActions
@ -10,10 +10,11 @@ import com.tangem.core.ui.extensions.TextReference
internal data class TokenActionsUM(
val token: TokenItemState,
val quickActions: QuickActions,
val bottomActionText: TextReference,
val bottomActionText: TextReference?,
val onBottomActionClick: () -> Unit,
val isBalancesHidden: Boolean = false,
val portfolioBadge: PortfolioBadgeUM = PortfolioBadgeUM.None,
val isCompact: Boolean = false,
)
@Immutable

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio
package com.tangem.features.commonfeatures.impl.userportfolio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -7,7 +7,7 @@ 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 com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

View file

@ -1,8 +1,8 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio
package com.tangem.features.commonfeatures.impl.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 com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
import kotlinx.coroutines.flow.StateFlow
internal interface UserPortfolioComponent : ComposableContentComponent {

View file

@ -1,9 +1,9 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
package com.tangem.features.commonfeatures.impl.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.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model
package com.tangem.features.commonfeatures.impl.userportfolio.model
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state
package com.tangem.features.commonfeatures.impl.userportfolio.state
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -7,8 +7,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
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 com.tangem.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
import com.tangem.features.commonfeatures.impl.userportfolio.transformer.UserPortfolioSectionsTransformer
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

View file

@ -1,4 +1,4 @@
package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer
package com.tangem.features.commonfeatures.impl.userportfolio.transformer
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.ui.account.toUM
@ -21,7 +21,7 @@ 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.features.commonfeatures.impl.userportfolio.model.UserPortfolioUM
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList

View file

@ -0,0 +1,258 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.card.WalletData
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ProductType
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.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.test.core.getEmittedValues
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MarketBlockDelegateTest {
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory = mockk()
private val excludedBlockchains: ExcludedBlockchains = mockk(relaxed = true)
private val getUserWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true)
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory = mockk()
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
private val defaultManager: MarketsListBatchFlowManager = mockk(relaxed = true)
private val searchManager: MarketsListBatchFlowManager = mockk(relaxed = true)
private val searchQueryState = MutableStateFlow(SearchQuery.Empty)
private val defaultUiItems = MutableStateFlow<ImmutableList<MarketsListItemUM>>(persistentListOf())
// Keyed by the raw id value: CryptoCurrency.RawID is a value class, unboxed to String at the JVM boundary.
private val tokenMarketsByRawId = mutableMapOf<String, TokenMarket>()
@BeforeEach
fun setup() {
clearMocks(
marketsListBatchFlowManagerFactory,
addToPortfolioManagerFactory,
singleAccountStatusListSupplier,
defaultManager,
searchManager,
)
searchQueryState.value = SearchQuery.Empty
defaultUiItems.value = persistentListOf()
tokenMarketsByRawId.clear()
every {
marketsListBatchFlowManagerFactory.create(
GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
any(),
any(),
any()
)
} returns defaultManager
every {
marketsListBatchFlowManagerFactory.create(
GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
any(),
any(),
any()
)
} returns searchManager
every { addToPortfolioManagerFactory.create(any(), any(), any()) } returns mockk(relaxed = true)
every { defaultManager.uiItems } returns defaultUiItems
every { defaultManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
every { defaultManager.totalCount } returns MutableStateFlow(null)
every { defaultManager.getTokenMarketById(any()) } answers { tokenMarketsByRawId[firstArg<String>()] }
every { searchManager.uiItems } returns MutableStateFlow(persistentListOf())
every { searchManager.isInInitialLoadingErrorState } returns MutableStateFlow(false)
every { searchManager.isSearchNotFoundState } returns MutableStateFlow(false)
every { searchManager.totalCount } returns MutableStateFlow(null)
every { searchManager.getTokenMarketById(any()) } returns null
}
@Test
fun `GIVEN multi-currency wallet WHEN trending emitted THEN all items shown unchanged`() = runTest {
// Arrange
val item1 = marketItem("token-1")
val item2 = marketItem("token-2")
defaultUiItems.value = persistentListOf(item1, item2)
val delegate = createDelegate(wallet = MockUserWalletFactory.create())
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(item1, item2).inOrder()
}
@Test
fun `GIVEN single-currency wallet WHEN trending emitted THEN market block is hidden`() = runTest {
// Arrange
defaultUiItems.value = persistentListOf(marketItem("token-1"))
val delegate = createDelegate(wallet = createSingleCurrencyWallet())
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN single-currency wallets not shown WHEN trending emitted THEN base state returned without filtering`() =
runTest {
// Arrange
val item1 = marketItem("token-1")
defaultUiItems.value = persistentListOf(item1)
// Single-currency wallet would normally hide the block, but the setting short-circuits the per-wallet logic.
val delegate = createDelegate(wallet = createSingleCurrencyWallet(), showSingleCurrencyWallets = false)
// Act
val result = lastMarketState(delegate)
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(item1)
}
@Test
fun `GIVEN NODL wallet WHEN trending emitted THEN only items on wallet network are shown`() = runTest {
// Arrange
val nodlWallet = MockUserWalletFactory.createSingleWalletWithToken()
val itemOnWalletNetwork = marketItem("token-stellar")
val itemOnOtherNetwork = marketItem("token-eth")
tokenMarketsByRawId["token-stellar"] = tokenMarket(STELLAR_NETWORK_ID)
tokenMarketsByRawId["token-eth"] = tokenMarket(ETHEREUM_NETWORK_ID)
defaultUiItems.value = persistentListOf(itemOnWalletNetwork, itemOnOtherNetwork)
every {
singleAccountStatusListSupplier(nodlWallet.walletId)
} returns flowOf(accountStatusList(STELLAR_NETWORK_ID))
// Act
val result = lastMarketState(createDelegate(wallet = nodlWallet))
// Assert
assertThat(result).isInstanceOf(SwapMarketState.Content::class.java)
assertThat((result as SwapMarketState.Content).items).containsExactly(itemOnWalletNetwork)
assertThat(result.total).isEqualTo(1)
}
@Test
fun `GIVEN NODL wallet WHEN no trending tokens on wallet network THEN market block is hidden`() = runTest {
// Arrange
val nodlWallet = MockUserWalletFactory.createSingleWalletWithToken()
val itemOnOtherNetwork = marketItem("token-eth")
tokenMarketsByRawId["token-eth"] = tokenMarket(ETHEREUM_NETWORK_ID)
defaultUiItems.value = persistentListOf(itemOnOtherNetwork)
every {
singleAccountStatusListSupplier(nodlWallet.walletId)
} returns flowOf(accountStatusList(STELLAR_NETWORK_ID))
// Act
val result = lastMarketState(createDelegate(wallet = nodlWallet))
// Assert
assertThat(result).isNull()
}
// region Helpers
private fun TestScope.lastMarketState(delegate: MarketBlockDelegate): SwapMarketState? {
val emittedValues = getEmittedValues(delegate.marketsStateFlow)
advanceUntilIdle()
return emittedValues.last()
}
private fun TestScope.createDelegate(
wallet: UserWallet,
showSingleCurrencyWallets: Boolean = true,
): MarketBlockDelegate {
val selectedWalletFlow = MutableSharedFlow<UserWallet>(replay = 1)
selectedWalletFlow.tryEmit(wallet)
return MarketBlockDelegate(
marketsListBatchFlowManagerFactory = marketsListBatchFlowManagerFactory,
excludedBlockchains = excludedBlockchains,
getUserWalletsUseCase = getUserWalletsUseCase,
addToPortfolioManagerFactory = addToPortfolioManagerFactory,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
modelScope = backgroundScope,
searchQueryState = searchQueryState,
screensSourcesName = "test",
selectedWalletFlow = selectedWalletFlow,
shouldShowSingleCurrencyWallets = showSingleCurrencyWallets,
)
}
private fun marketItem(id: String): MarketsListItemUM = mockk {
every { this@mockk.id } returns CryptoCurrency.RawID(id)
}
private fun tokenMarket(vararg networkIds: String): TokenMarket = mockk {
every { networks } returns networkIds.map { networkId ->
TokenMarket.Network(networkId = networkId, contractAddress = null, decimalCount = null)
}
}
private fun accountStatusList(vararg networkIds: String): AccountStatusList = mockk {
every { flattenCurrencies() } returns networkIds.map { networkId ->
mockk<CryptoCurrencyStatus> {
every { currency.network.rawId } returns networkId
}
}
}
private fun createSingleCurrencyWallet(): UserWallet.Cold = UserWallet.Cold(
name = "Single",
walletId = UserWalletId("022"),
cardsInWallet = emptySet(),
isMultiCurrency = false,
scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(maxWalletCount = 2),
derivedKeys = emptyMap(),
).copy(
productType = ProductType.Note,
walletData = WalletData(blockchain = "XLM", token = null),
),
hasBackupError = false,
)
// endregion
private companion object {
const val STELLAR_NETWORK_ID = "stellar"
const val ETHEREUM_NETWORK_ID = "ethereum"
}
}