Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-04 19:37:14 +04:00
commit 304bf0df87
928 changed files with 23076 additions and 29831 deletions

View file

@ -15,7 +15,7 @@ interface AccountCreateEditComponent : ComposableContentComponent {
) : Params
data class Edit(
val account: Account,
val account: Account.CryptoPortfolio,
) : Params
}
}

View file

@ -7,5 +7,5 @@ import com.tangem.domain.models.account.Account
interface AccountDetailsComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AccountDetailsComponent>
data class Params(val account: Account)
data class Params(val account: Account.CryptoPortfolio)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.account.createedit
import androidx.annotation.StringRes
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toDomain
@ -25,7 +26,6 @@ import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.analytics.AccountSettingsAnalyticEvents
@ -73,7 +73,7 @@ internal class AccountCreateEditModel @Inject constructor(
when (params) {
is AccountCreateEditComponent.Params.Create -> updateDerivationInfo(userWalletId = params.userWalletId)
is AccountCreateEditComponent.Params.Edit -> {
val derivationIndex = params.account.derivationIndex?.value
val derivationIndex = params.account.derivationIndex.value
val event = AccountSettingsAnalyticEvents.AccountEditScreenOpened(derivationIndex)
analyticsEventHandler.send(event)
}
@ -135,10 +135,14 @@ internal class AccountCreateEditModel @Inject constructor(
result
.onLeft { error -> handleAddAccountError(error, derivationIndex.value) }
.onRight {
.onRight { account ->
analyticsEventHandler.send(WalletSettingsAccountAnalyticEvents.AccountCreated())
showMessage(R.string.account_create_success_message)
router.pop()
val route = AppRoute.ManageTokens(
source = AppRoute.ManageTokens.Source.ACCOUNT,
accountId = account.accountId,
)
router.replaceCurrent(route)
}
}
@ -170,7 +174,7 @@ internal class AccountCreateEditModel @Inject constructor(
val icon = CryptoPortfolioIconConverter.convertBack(state.account.portfolioIcon)
val isNewName = name != params.account.accountName
val isNewIcon = icon != params.account.portfolioIcon
val derivationIndex = params.account.derivationIndex?.value
val derivationIndex = params.account.derivationIndex.value
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonSave(name, icon, derivationIndex))
uiState.value = uiState.value.toggleProgress(showProgress = true)
@ -182,7 +186,7 @@ internal class AccountCreateEditModel @Inject constructor(
uiState.value = uiState.value.toggleProgress(showProgress = false)
result
.onLeft { error -> handleEditAccountError(error, params.account.derivationIndex?.value) }
.onLeft { error -> handleEditAccountError(error, params.account.derivationIndex.value) }
.onRight {
showMessage(R.string.account_edit_success_message)
router.pop()

View file

@ -14,12 +14,9 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.ToastMessage
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.status.usecase.ArchiveCryptoPortfolioUseCase
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.models.PortfolioId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.account.AccountDetailsComponent
@ -54,29 +51,29 @@ internal class AccountDetailsModel @Inject constructor(
init {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.AccountSettingsScreenOpened())
singleAccountSupplier(SingleAccountProducer.Params(accountId))
singleAccountSupplier.filterCryptoPortfolioAccount(accountId)
.onEach { account -> uiState.update { buildUI(account) } }
.launchIn(modelScope)
}
private fun onEditAccountClick(account: Account) {
private fun onEditAccountClick(account: Account.CryptoPortfolio) {
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonEdit())
router.push(AppRoute.EditAccount(account))
}
private fun onManageTokensClick(account: Account) {
private fun onManageTokensClick(account: Account.CryptoPortfolio) {
val route = AppRoute.ManageTokens(
source = AppRoute.ManageTokens.Source.ACCOUNT,
portfolioId = PortfolioId(account.accountId),
accountId = account.accountId,
)
analyticsEventHandler.send(
AccountSettingsAnalyticEvents.ButtonManageTokens(account.derivationIndex?.value),
AccountSettingsAnalyticEvents.ButtonManageTokens(account.derivationIndex.value),
)
router.push(route)
}
private fun onArchiveAccountClick() {
val accountDerivation = params.account.derivationIndex?.value
val accountDerivation = params.account.derivationIndex.value
val event = AccountSettingsAnalyticEvents.ButtonArchiveAccount(accountDerivation)
analyticsEventHandler.send(event)
confirmArchiveDialog()
@ -86,7 +83,7 @@ internal class AccountDetailsModel @Inject constructor(
val secondAction = EventMessageAction(
title = resourceReference(R.string.common_cancel),
onClick = {
val accountDerivation = params.account.derivationIndex?.value
val accountDerivation = params.account.derivationIndex.value
val event = AccountSettingsAnalyticEvents.ButtonCancelAccountArchivation(accountDerivation)
analyticsEventHandler.send(event)
},
@ -107,7 +104,7 @@ internal class AccountDetailsModel @Inject constructor(
}
private fun archiveCryptoPortfolio() = modelScope.launch {
val accountDerivation = params.account.derivationIndex?.value
val accountDerivation = params.account.derivationIndex.value
val event = AccountSettingsAnalyticEvents.ButtonArchiveAccountConfirmation(accountDerivation)
analyticsEventHandler.send(event)
uiState.update { it.toggleProgress(true) }
@ -128,7 +125,7 @@ internal class AccountDetailsModel @Inject constructor(
val event = AccountSettingsAnalyticEvents.AccountError(
source = AccountSettingsAnalyticEvents.Source.ARCHIVE,
error = error.tag,
accountDerivation = params.account.derivationIndex?.value,
accountDerivation = params.account.derivationIndex.value,
)
analyticsEventHandler.send(event)
val titleRes: Int
@ -155,16 +152,13 @@ internal class AccountDetailsModel @Inject constructor(
messageSender.send(dialogMessage)
}
private fun buildUI(account: Account): AccountDetailsUM {
val archiveMode = when (account) {
is Account.CryptoPortfolio -> when (account.isMainAccount) {
true -> ArchiveMode.None
false -> ArchiveMode.Available(
onArchiveAccountClick = ::onArchiveAccountClick,
isLoading = false,
)
}
is Account.Payment -> TODO("[REDACTED_JIRA]")
private fun buildUI(account: Account.CryptoPortfolio): AccountDetailsUM {
val archiveMode = when (account.isMainAccount) {
true -> ArchiveMode.None
false -> ArchiveMode.Available(
onArchiveAccountClick = ::onArchiveAccountClick,
isLoading = false,
)
}
val isMultiCurrency = getUserWalletUseCase(account.accountId.userWalletId).getOrNull()
?.isMultiCurrency == true

View file

@ -0,0 +1,28 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.approval.api"
}
dependencies {
/** Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
/** Common */
implementation(projects.common.ui)
/** Other */
implementation(deps.kotlin.immutable.collections)
/** Compose */
implementation(deps.compose.runtime)
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.approval.api
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
interface GiveApprovalComponent : ComposableBottomSheetComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val amount: String,
val spenderAddress: String,
val subtitle: TextReference,
val callback: Callback,
)
interface Callback {
fun onApproveClick()
fun onApproveDone()
fun onApproveFailed()
fun onCancelClick()
}
interface Factory {
fun create(context: AppComponentContext, params: Params): GiveApprovalComponent
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.approval.api
interface GiveApprovalFeatureToggles {
val isGaslessApprovalEnabled: Boolean
}

View file

@ -0,0 +1,60 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.features.approval.impl"
}
dependencies {
/** Feature */
implementation(projects.features.approval.api)
implementation(projects.features.sendV2.api)
/** Core */
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
/** Common */
implementation(projects.common.ui)
/** SDK */
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.transaction.models)
implementation(projects.domain.transaction)
/** Compose */
implementation(deps.compose.foundation)
implementation(deps.compose.runtime)
implementation(deps.compose.material3)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
/** Other */
implementation(deps.decompose)
implementation(deps.decompose.ext.compose)
implementation(deps.timber)
implementation(deps.kotlin.immutable.collections)
implementation(deps.arrow.core)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.approval.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.impl.model.GiveApprovalModel
import com.tangem.features.approval.impl.ui.GiveApprovalContent
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.common.ui.R as CommonUiR
internal class DefaultGiveApprovalComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: GiveApprovalComponent.Params,
feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory,
) : GiveApprovalComponent, AppComponentContext by appComponentContext {
private val model: GiveApprovalModel = getOrCreateModel(params = params)
private val feeSelectorBlockComponent = feeSelectorBlockComponentFactory.create(
context = child("giveApprovalFeeSelector"),
params = FeeSelectorParams.FeeSelectorBlockParams(
state = FeeSelectorUM.Loading,
onLoadFee = { model.loadFee() },
onLoadFeeExtended = { selectedFeeToken -> model.loadFeeExtended(selectedFeeToken) },
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.None,
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
analyticsCategoryName = CommonSendAnalyticEvents.APPROVE_CATEGORY,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Approve,
userWalletId = params.userWalletId,
),
onResult = model::onFeeResult,
)
private val currency: String = params.cryptoCurrencyStatus.currency.symbol
override fun dismiss() {
params.callback.onCancelClick()
}
@Composable
override fun BottomSheet() {
val uiState by model.uiState.collectAsStateWithLifecycle()
val config = remember {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
containerColor = TangemTheme.colors.background.secondary,
titleText = resourceReference(CommonUiR.string.give_permission_title),
titleAction = TopAppBarButtonUM.Icon(
iconRes = CommonUiR.drawable.ic_information_24,
onClicked = model::showPermissionInfoDialog,
),
) {
GiveApprovalContent(
currency = currency,
subtitle = params.subtitle,
approveType = uiState.approveType,
approveItems = uiState.approveItems,
onChangeApproveType = model::onChangeApproveType,
walletInteractionIcon = uiState.walletInteractionIcon,
isApproveEnabled = uiState.isApproveButtonEnabled,
isApproveLoading = uiState.isApproveLoading,
onApproveClick = model::onApproveClick,
onCancelClick = model::onCancelClick,
onOpenLearnMoreAboutApproveClick = model::onOpenLearnMoreAboutApproveClick,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
@AssistedFactory
interface Factory : GiveApprovalComponent.Factory {
override fun create(
context: AppComponentContext,
params: GiveApprovalComponent.Params,
): DefaultGiveApprovalComponent
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.approval.impl
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import javax.inject.Inject
internal class DefaultGiveApprovalFeatureToggles @Inject constructor(
private val featureToggles: FeatureTogglesManager,
) : GiveApprovalFeatureToggles {
// Remove GiveTxPermissionBottomSheet and all dependencies with this toggle
override val isGaslessApprovalEnabled: Boolean
get() = featureToggles.isFeatureEnabled("GASLESS_APPROVAL_ENABLED")
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.approval.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import com.tangem.features.approval.impl.DefaultGiveApprovalComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles
import com.tangem.features.approval.impl.model.GiveApprovalModel
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
@InstallIn(SingletonComponent::class)
@Module
internal interface GiveApprovalFeatureModule {
@Singleton
@Binds
fun bindGiveApprovalFeatureToggle(toggles: DefaultGiveApprovalFeatureToggles): GiveApprovalFeatureToggles
@Binds
@Singleton
fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface GiveApprovalModelModule {
@Binds
@IntoMap
@ClassKey(GiveApprovalModel::class)
fun bindModel(model: GiveApprovalModel): Model
}

View file

@ -0,0 +1,243 @@
package com.tangem.features.approval.impl.model
import androidx.compose.runtime.Stable
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
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.message.DialogMessage
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class GiveApprovalModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase,
private val getFeeForTokenUseCase: GetFeeForTokenUseCase,
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
private val uiMessageSender: UiMessageSender,
private val urlOpener: UrlOpener,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model(), FeeSelectorModelCallback {
private val params: GiveApprovalComponent.Params = paramsContainer.require()
private val userWallet by lazy {
requireNotNull(
getUserWalletUseCase(params.userWalletId).getOrNull(),
) { "No wallet found for id: $params.userWalletId" }
}
val uiState: StateFlow<GiveApprovalUM>
field = MutableStateFlow(
GiveApprovalUM(
approveType = ApproveType.LIMITED,
walletInteractionIcon = walletInterationIcon(userWallet),
isApproveButtonEnabled = false,
isApproveLoading = false,
),
)
private var feeSelectorUM: FeeSelectorUM = FeeSelectorUM.Loading
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
this.feeSelectorUM = feeSelectorUM
uiState.update { it.copy(isApproveButtonEnabled = feeSelectorUM.isPrimaryButtonEnabled) }
}
fun onApproveClick() {
params.callback.onApproveClick()
uiState.update { it.copy(isApproveLoading = true) }
modelScope.launch(dispatchers.main) {
val isSuccess = sendApprovalTransaction()
uiState.update { it.copy(isApproveLoading = false) }
if (isSuccess) {
params.callback.onApproveDone()
} else {
params.callback.onApproveFailed()
}
}
}
fun onCancelClick() {
params.callback.onCancelClick()
}
fun onChangeApproveType(approveType: ApproveType) {
uiState.update { it.copy(approveType = approveType) }
}
fun onOpenLearnMoreAboutApproveClick() {
urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP)
}
fun showPermissionInfoDialog() {
uiMessageSender.send(
DialogMessage(
message = resourceReference(com.tangem.common.ui.R.string.give_permission_staking_footer),
title = resourceReference(com.tangem.common.ui.R.string.common_approve),
),
)
}
suspend fun prepareApprovalTransaction(): Either<Throwable, TransactionData> {
val cryptoCurrencyStatus = params.cryptoCurrencyStatus
val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token
?: return Either.Left(IllegalStateException("Currency is not a token"))
return createApprovalTransactionUseCase(
cryptoCurrencyStatus = cryptoCurrencyStatus,
userWalletId = params.userWalletId,
amount = getApprovalAmount(),
contractAddress = tokenCurrency.contractAddress,
spenderAddress = params.spenderAddress,
)
}
suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
val approvalTransaction = prepareApprovalTransaction()
.getOrElse { return GetFeeError.DataError(it).left() }
return getFeeUseCase(
transactionData = approvalTransaction,
userWallet = userWallet,
network = params.cryptoCurrencyStatus.currency.network,
)
}
suspend fun loadFeeExtended(maybeToken: CryptoCurrencyStatus?): Either<GetFeeError, TransactionFeeExtended> {
val approvalTransaction = prepareApprovalTransaction()
.getOrElse { return GetFeeError.DataError(it).left() }
return if (maybeToken == null) {
getFeeForGaslessUseCase(
transactionData = approvalTransaction,
userWallet = userWallet,
network = params.cryptoCurrencyStatus.currency.network,
)
} else {
getFeeForTokenUseCase(
transactionData = approvalTransaction,
userWallet = userWallet,
token = maybeToken.currency,
)
}
}
private suspend fun sendApprovalTransaction(): Boolean {
val cryptoCurrencyStatus = params.cryptoCurrencyStatus
val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return false
val feeContent = feeSelectorUM as? FeeSelectorUM.Content ?: return false
val selectedFee = feeContent.selectedFeeItem.fee
val feeExtended = feeContent.feeExtraInfo.transactionFeeExtended
val isFeeInTokenCurrency = feeExtended?.transactionFee?.normal is Fee.Ethereum.TokenCurrency
val transactionData = createApprovalTransactionUseCase(
cryptoCurrencyStatus = cryptoCurrencyStatus,
userWalletId = params.userWalletId,
amount = getApprovalAmount(),
fee = selectedFee,
contractAddress = tokenCurrency.contractAddress,
spenderAddress = params.spenderAddress,
).getOrElse { error ->
Timber.e(error, "Failed to create approval transaction")
return false
}
return if (isFeeInTokenCurrency) {
createAndSendGaslessTransactionUseCase(
userWallet = userWallet,
transactionData = transactionData,
fee = feeExtended,
)
} else {
sendTransactionUseCase(
txData = transactionData,
userWallet = userWallet,
network = tokenCurrency.network,
)
}.fold(
ifLeft = { error ->
Timber.e("Failed to send approval transaction: $error")
false
},
ifRight = {
sendApproveSuccessAnalytics(feeContent)
true
},
)
}
private fun sendApproveSuccessAnalytics(feeContent: FeeSelectorUM.Content) {
val currency = params.cryptoCurrencyStatus.currency
val feeToken = feeContent.feeExtraInfo.feeCryptoCurrencyStatus.currency.symbol
val permissionType = when (uiState.value.approveType) {
ApproveType.LIMITED -> "Current transaction"
ApproveType.UNLIMITED -> "Unlimited"
}
val event = AnalyticsParam.TxSentFrom.Approve(
blockchain = currency.network.name,
token = currency.symbol,
feeType = feeContent.toAnalyticType(),
feeToken = feeToken,
permissionType = permissionType,
)
analyticsEventHandler.send(
Basic.TransactionSent(
sentFrom = event,
memoType = Basic.TransactionSent.MemoType.Null,
),
)
}
private fun getApprovalAmount(): BigDecimal? {
return if (uiState.value.approveType == ApproveType.LIMITED) {
params.amount.toBigDecimalOrNull()
} else {
null
}
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.approval.impl.model
import androidx.annotation.DrawableRes
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal data class GiveApprovalUM(
val approveType: ApproveType,
val approveItems: ImmutableList<ApproveType> = ApproveType.entries.toImmutableList(),
@DrawableRes val walletInteractionIcon: Int?,
val isApproveButtonEnabled: Boolean,
val isApproveLoading: Boolean,
)

View file

@ -0,0 +1,353 @@
package com.tangem.features.approval.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
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.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
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.DpOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.window.PopupProperties
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.containers.FooterContainer
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import com.tangem.common.ui.R as CommonUiR
@Composable
@Suppress("LongParameterList")
internal fun GiveApprovalContent(
currency: String,
subtitle: TextReference,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
walletInteractionIcon: Int?,
isApproveEnabled: Boolean,
isApproveLoading: Boolean,
onApproveClick: () -> Unit,
onCancelClick: () -> Unit,
onOpenLearnMoreAboutApproveClick: () -> Unit,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = subtitle.resolveReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing24),
)
SpacerH16()
ApprovalInfo(
currency = currency,
approveType = approveType,
approveItems = approveItems,
onChangeApproveType = onChangeApproveType,
onOpenLearnMoreAboutApproveClick = onOpenLearnMoreAboutApproveClick,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
SpacerH(height = TangemTheme.dimens.spacing20)
PrimaryButtonIconEnd(
text = stringResourceSafe(id = CommonUiR.string.common_approve),
iconResId = walletInteractionIcon,
showProgress = isApproveLoading,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
onClick = onApproveClick,
enabled = isApproveEnabled,
)
SpacerH12()
SecondaryButton(
text = stringResourceSafe(id = CommonUiR.string.common_cancel),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
onClick = onCancelClick,
)
SpacerH16()
}
}
@Suppress("LongParameterList")
@Composable
private fun ApprovalInfo(
currency: String,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
onOpenLearnMoreAboutApproveClick: () -> Unit,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
) {
FooterContainer(
footer = annotatedReference {
append(stringResourceSafe(CommonUiR.string.swap_approve_description))
append(" ")
withLink(
link = LinkAnnotation.Clickable(
tag = "APPROVE_TAG",
linkInteractionListener = { onOpenLearnMoreAboutApproveClick() },
),
block = {
appendColored(
text = stringResourceSafe(CommonUiR.string.common_learn_more),
color = TangemTheme.colors.text.accent,
)
},
)
},
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
AmountItem(
currency = currency,
approveType = approveType,
onChangeApproveType = onChangeApproveType,
approveItems = approveItems,
)
}
SpacerH16()
FooterContainer(
footer = resourceReference(CommonUiR.string.give_permission_policy_type_footer),
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
feeSelectorBlockComponent.Content(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
}
}
@Composable
private fun AmountItem(
currency: String,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
) {
var isExpandSelector by remember { mutableStateOf(false) }
var amountSize by remember { mutableStateOf(IntSize.Zero) }
Box(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = { isExpandSelector = true },
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { amountSize = it }
.padding(
top = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(id = CommonUiR.string.give_permission_rows_amount, currency),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
SpacerWMax()
Text(
text = approveType.text.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body1,
maxLines = 1,
)
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(id = CommonUiR.drawable.ic_chevron_24)),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
)
}
DropdownSelector(
isExpanded = isExpandSelector,
onDismiss = { isExpandSelector = false },
onItemClick = { type ->
isExpandSelector = false
onChangeApproveType(type)
},
items = approveItems,
selectedType = approveType,
amountSize = amountSize,
)
}
}
@Suppress("LongParameterList")
@Composable
private fun DropdownSelector(
isExpanded: Boolean,
onDismiss: () -> Unit,
onItemClick: (ApproveType) -> Unit,
items: ImmutableList<ApproveType>,
selectedType: ApproveType,
amountSize: IntSize,
) {
var dropDownWidth by remember { mutableStateOf(IntSize.Zero) }
val offsetY = amountSize.height.times(-1)
val offsetX = amountSize.width - dropDownWidth.width
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(surface = TangemTheme.colors.background.action),
shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(TangemTheme.dimens.radius16)),
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
properties = PopupProperties(clippingEnabled = false),
offset = with(LocalDensity.current) {
DpOffset(x = offsetX.toDp(), y = offsetY.toDp())
},
modifier = Modifier
.wrapContentSize()
.background(TangemTheme.colors.background.action)
.onSizeChanged { dropDownWidth = it },
) {
items.forEach { item ->
val color = if (item == selectedType) TangemTheme.colors.icon.accent else Color.Transparent
DropdownMenuItem(
modifier = Modifier.fillMaxWidth(),
text = {
Row {
Text(
text = when (item) {
ApproveType.LIMITED -> stringResourceSafe(
id = CommonUiR.string.give_permission_current_transaction,
)
ApproveType.UNLIMITED -> stringResourceSafe(
id = CommonUiR.string.give_permission_unlimited,
)
},
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
SpacerWMax()
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = CommonUiR.drawable.ic_check_24),
),
tint = color,
contentDescription = null,
modifier = Modifier.padding(start = TangemTheme.dimens.size20),
)
}
},
onClick = {
onItemClick.invoke(item)
},
)
}
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun GiveApprovalContentPreview(
@PreviewParameter(GiveApprovalContentPreviewProvider::class) params: GiveApprovalPreviewParams,
) {
TangemThemePreview {
GiveApprovalContent(
currency = params.currency,
subtitle = params.subtitle,
approveType = params.approveType,
approveItems = params.approveItems,
onChangeApproveType = {},
walletInteractionIcon = params.walletInteractionIcon,
isApproveEnabled = params.isApproveEnabled,
isApproveLoading = params.isApproveLoading,
onApproveClick = {},
onCancelClick = {},
onOpenLearnMoreAboutApproveClick = {},
feeSelectorBlockComponent = PreviewFeeSelectorBlockComponent(),
)
}
}
private data class GiveApprovalPreviewParams(
val currency: String,
val subtitle: TextReference,
val approveType: ApproveType,
val approveItems: ImmutableList<ApproveType>,
val walletInteractionIcon: Int?,
val isApproveEnabled: Boolean,
val isApproveLoading: Boolean,
)
private class GiveApprovalContentPreviewProvider : PreviewParameterProvider<GiveApprovalPreviewParams> {
override val values: Sequence<GiveApprovalPreviewParams>
get() = sequenceOf(
GiveApprovalPreviewParams(
currency = "USDT",
subtitle = stringReference("Allow this app to access your USDT"),
approveType = ApproveType.LIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
walletInteractionIcon = CommonUiR.drawable.ic_tangem_24,
isApproveEnabled = true,
isApproveLoading = false,
),
GiveApprovalPreviewParams(
currency = "USDC",
subtitle = stringReference("Allow this app to access your USDC"),
approveType = ApproveType.UNLIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
walletInteractionIcon = CommonUiR.drawable.ic_tangem_24,
isApproveEnabled = false,
isApproveLoading = true,
),
)
}
// endregion

View file

@ -0,0 +1,15 @@
package com.tangem.features.approval.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent {
override fun updateState(feeSelectorUM: FeeSelectorUM) {
}
@Composable
override fun Content(modifier: Modifier) {
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.feed.entry.featuretoggle
interface FeedFeatureToggle {
val isFeedEnabled: Boolean
val isEarnBlockEnabled: Boolean
}

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.domain.feedback.models)
implementation(projects.domain.manageTokens)
implementation(projects.domain.markets)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.tokens)
@ -56,12 +57,6 @@ dependencies {
implementation(projects.domain.yieldSupply)
implementation(projects.domain.earn)
// FIXME [REDACTED_TASK_KEY]
// Remove the "Buy" and "Sell" actions from the redux middleware.
// Instead, create some kind of interface for such cases.
/* Redux -_- */
implementation(projects.domain.legacy)
implementation(deps.reKotlin)
/* Compose */
implementation(deps.compose.coil)

View file

@ -6,7 +6,6 @@ import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
@ -20,7 +19,6 @@ import javax.inject.Inject
internal class FeedEntryChildFactory @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory,
) {
@ -66,7 +64,6 @@ internal class FeedEntryChildFactory @Inject constructor(
appComponentContext = appComponentContext,
params = child.params,
analyticsEventHandler = analyticsEventHandler,
accountsFeatureToggles = accountsFeatureToggles,
portfolioComponentFactory = portfolioComponentFactory,
)
}

View file

@ -15,7 +15,6 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
@ -33,7 +32,6 @@ internal class DefaultMarketsTokenDetailsComponent(
appComponentContext: AppComponentContext,
val params: Params,
analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
portfolioComponentFactory: MarketsPortfolioComponent.Factory,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
@ -117,7 +115,6 @@ internal class DefaultMarketsTokenDetailsComponent(
modifier = modifier,
backgroundColor = LocalMainBottomSheetColor.current.value,
state = state,
isAccountEnabled = accountsFeatureToggles.isFeatureEnabled,
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)

View file

@ -58,7 +58,6 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnsafeCallOnNullableType")
private fun bottomSheetChild(
config: MarketsPortfolioRoute,
componentContext: ComponentContext,
@ -66,7 +65,7 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor(
MarketsPortfolioRoute.AddToPortfolio -> addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.newAddToPortfolioManager!!,
addToPortfolioManager = model.addToPortfolioManager,
callback = model.addToPortfolioCallback,
),
)

View file

@ -1,135 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.loader
import arrow.core.getOrElse
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetAllWalletsCryptoCurrencyStatusesUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.yield.supply.models.YieldSupplyAvailability
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import javax.inject.Inject
/**
* Loader of portfolio data
*
* @property getAllWalletsCryptoCurrencyStatusesUseCase use case for getting all wallets crypto currency statuses
* @property getSelectedAppCurrencyUseCase use case for getting selected app currency
* @property getBalanceHidingSettingsUseCase use case for getting balance hiding settings
* @property getWalletTotalBalanceUseCase use case for getting wallet total balance
*
[REDACTED_AUTHOR]
*/
internal class PortfolioDataLoader @Inject constructor(
private val getAllWalletsCryptoCurrencyStatusesUseCase: GetAllWalletsCryptoCurrencyStatusesUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase,
private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase,
) {
/** Load data by [currencyRawId] */
@OptIn(ExperimentalCoroutinesApi::class)
fun load(currencyRawId: CryptoCurrency.RawID): Flow<PortfolioData> {
return combine(
flow = getAllWalletsCryptoCurrenciesData(currencyRawId = currencyRawId),
flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { walletsWithCurrencies, appCurrency, isBalanceHidden ->
PortfolioData(
walletsWithCurrencies = walletsWithCurrencies,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
walletsWithBalance = emptyMap(),
)
}
// setup balances for wallets from walletsWithCurrencyStatuses
.flatMapLatest { portfolioData ->
getWalletsWithTotalBalanceFlow(
ids = portfolioData.walletsWithCurrencies.keys.map(UserWallet::walletId),
)
.map { portfolioData.copy(walletsWithBalance = it) }
.onEmpty { emit(portfolioData) }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun getAllWalletsCryptoCurrenciesData(
currencyRawId: CryptoCurrency.RawID,
): Flow<Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>> {
return getAllWalletsCryptoCurrencyStatusesUseCase(currencyRawId)
.distinctUntilChanged()
.map { walletsWithMaybeStatuses ->
walletsWithMaybeStatuses.mapValues { entry ->
entry.value.mapNotNull { it.getOrNull() }
}
}
.flatMapLatest { walletsWithStatuses ->
val actionsFlows = walletsWithStatuses.flatMap { (wallet, statuses) ->
statuses.map { status ->
val yieldSupplyAvailability = yieldSupplyGetAvailabilityUseCase(status.currency).getOrElse {
YieldSupplyAvailability.Unavailable
}
getCryptoCurrencyActionsUseCase(wallet, status, yieldSupplyAvailability)
.map { tokenActionsState ->
PortfolioData.CryptoCurrencyData(
userWallet = wallet,
status = status,
actions = tokenActionsState.states,
)
}
}
}
combine(actionsFlows) { actions ->
walletsWithStatuses.mapValues { entry ->
entry.value.mapNotNull { status ->
actions.firstOrNull {
it.userWallet == entry.key && it.status == status
}
}
}
}.onEmpty {
emit(
walletsWithStatuses.mapValues { (wallet, statuses) ->
statuses.map { status ->
PortfolioData.CryptoCurrencyData(
userWallet = wallet,
status = status,
actions = emptyList(),
)
}
},
)
}
}.onEmpty {
emit(emptyMap())
}
.distinctUntilChanged()
}
private fun getWalletsWithTotalBalanceFlow(
ids: List<UserWalletId>,
): Flow<Map<UserWalletId, Lce<TokenListError, TotalFiatBalance>>> {
return combine(
flows = ids
.map { userWalletId ->
getWalletTotalBalanceUseCase(userWalletId)
.map { userWalletId to it }
.distinctUntilChanged()
},
transform = { it.toMap() },
)
.distinctUntilChanged()
.onEmpty { ids.associateWith { Lce.Loading<TotalFiatBalance>(partialContent = null) } }
}
}

View file

@ -1,161 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM
import kotlinx.collections.immutable.toImmutableList
/**
* Factory to create AddToPortfolio bottom sheet content [TangemBottomSheetConfig]
*
* @property token token params
* @property onAddToPortfolioVisibilityChange callback is invoked when add to portfolio visibility is changed
* @property onWalletSelectorVisibilityChange callback is invoked when wallet selector visibility is changed
* @property onNetworkSwitchClick callback is invoked when network switch is clicked
* @property onAnotherWalletSelect callback is invoked when wallet is selected
* @property onContinueClick callback is invoked when continue button is clicked
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class AddToPortfolioBSContentUMFactory(
private val addToPortfolioManager: AddToPortfolioManager,
private val token: TokenMarketParams,
private val onAddToPortfolioVisibilityChange: (Boolean) -> Unit,
private val onWalletSelectorVisibilityChange: (Boolean) -> Unit,
private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit,
private val onAnotherWalletSelect: (UserWalletId) -> Unit,
private val onContinueClick: (selectedWalletId: UserWalletId, addedNetworks: Set<TokenMarketInfo.Network>) -> Unit,
) {
/**
* Create [TangemBottomSheetConfig]
*
* @param portfolioData portfolio data
* @param portfolioUIData portfolio bottom sheet visibility model
* @param selectedWallet selected wallet
* @param alreadyAddedNetworks already added networks
*/
@Suppress("LongParameterList")
fun create(
currentState: TangemBottomSheetConfig?,
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
selectedWallet: UserWallet?,
alreadyAddedNetworks: Set<String>?,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
return (currentState ?: TangemBottomSheetConfig.Empty).copy(
isShown = portfolioUIData.portfolioBSVisibilityModel.isAddToPortfolioBSVisible,
onDismissRequest = { onAddToPortfolioVisibilityChange(false) },
content = if (selectedWallet != null && alreadyAddedNetworks != null) {
AddToPortfolioBSContentUM(
selectedWallet = selectedWallet.toSelectedUserWalletItemUM(
portfolioData = portfolioData,
balance = portfolioData.walletsWithBalance[selectedWallet.walletId]?.getOrNull(),
artwork = artworks[selectedWallet.walletId],
),
selectNetworkUM = SelectNetworkUMConverter(
networksWithToggle = addToPortfolioManager.associateWithToggle(
userWalletId = selectedWallet.walletId,
alreadyAddedNetworkIds = alreadyAddedNetworks,
addToPortfolioData = portfolioUIData.addToPortfolioData,
),
alreadyAddedNetworks = alreadyAddedNetworks,
onNetworkSwitchClick = onNetworkSwitchClick,
).convert(value = token),
isScanCardNotificationVisible = portfolioUIData.isNeededColdWalletInteraction,
isContinueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks(
userWalletId = selectedWallet.walletId,
),
onContinueButtonClick = {
onContinueClick(
selectedWallet.walletId,
portfolioUIData.addToPortfolioData.getAddedNetworks(
userWalletId = selectedWallet.walletId,
alreadyAddedNetworkIds = alreadyAddedNetworks,
),
)
},
walletSelectorConfig = createWalletSelectorBSConfig(
isShow = portfolioUIData.portfolioBSVisibilityModel.isWalletSelectorBSVisible,
portfolioData = portfolioData,
selectedWalletId = selectedWallet.walletId,
artworks = artworks,
),
isWalletBlockVisible = portfolioData.walletsWithCurrencies
.filterKeys(UserWallet::isMultiCurrency).size > 1,
)
} else {
TangemBottomSheetConfigContent.Empty
},
)
}
private fun UserWallet.toSelectedUserWalletItemUM(
artwork: UserWalletItemUM.ImageState? = null,
portfolioData: PortfolioData,
balance: TotalFiatBalance?,
): UserWalletItemUM {
return UserWalletItemUMConverter(
onClick = { onWalletSelectorVisibilityChange(true) },
endIcon = UserWalletItemUM.EndIcon.Arrow,
balance = balance,
artwork = artwork,
appCurrency = portfolioData.appCurrency,
isBalanceHidden = portfolioData.isBalanceHidden,
).convert(value = this)
}
private fun createWalletSelectorBSConfig(
isShow: Boolean,
portfolioData: PortfolioData,
selectedWalletId: UserWalletId,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
return TangemBottomSheetConfig(
isShown = isShow,
onDismissRequest = { onWalletSelectorVisibilityChange(false) },
content = WalletSelectorBSContentUM(
userWallets = portfolioData.walletsWithCurrencies
.filterKeys(UserWallet::isMultiCurrency)
.map { it.key }
.map { userWallet ->
val balance = portfolioData.walletsWithBalance[userWallet.walletId]
UserWalletItemUMConverter(
onClick = { id ->
if (id != selectedWalletId) {
onAnotherWalletSelect(id)
onWalletSelectorVisibilityChange(false)
}
},
appCurrency = portfolioData.appCurrency,
balance = balance?.getOrNull(),
isBalanceHidden = portfolioData.isBalanceHidden,
endIcon = if (userWallet.walletId == selectedWalletId) {
UserWalletItemUM.EndIcon.Checkmark
} else {
UserWalletItemUM.EndIcon.None
},
artwork = artworks[userWallet.walletId],
).convert(userWallet)
}
.toImmutableList(),
onBack = { onWalletSelectorVisibilityChange(false) },
),
)
}
}

View file

@ -1,193 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import timber.log.Timber
import javax.inject.Inject
import kotlin.collections.firstOrNull
import kotlin.collections.orEmpty
import kotlin.collections.set
internal typealias WalletsWithNetworks = Map<UserWalletId, Set<TokenMarketInfo.Network>>
/**
* Manager for tracking changing networks in AddToPortfolio
*
[REDACTED_AUTHOR]
*/
internal class AddToPortfolioManager @Inject constructor(
private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase,
) {
val availableNetworks = MutableStateFlow<Set<TokenMarketInfo.Network>?>(value = null)
private val addedNetworks = MutableStateFlow<WalletsWithNetworks>(value = emptyMap())
private val removedNetworks = MutableStateFlow<WalletsWithNetworks>(value = emptyMap())
/** Get [AddToPortfolioData] as flow */
fun getAddToPortfolioData(): Flow<AddToPortfolioData> {
return combine(
flow = availableNetworks,
flow2 = addedNetworks,
flow3 = removedNetworks,
transform = ::AddToPortfolioData,
)
}
/** Set available networks [networks] */
fun setAvailableNetworks(networks: List<TokenMarketInfo.Network>) {
availableNetworks.value = networks.toSet()
}
/** Add network [networkId] to [userWalletId] */
fun addNetwork(userWalletId: UserWalletId, networkId: String) {
addedNetworks.add(userWalletId, networkId)
removedNetworks.cancelPrevChangeIfExist(userWalletId = userWalletId, networkId = networkId)
}
/** Remove network [networkId] from [userWalletId] */
fun removeNetwork(userWalletId: UserWalletId, networkId: String) {
removedNetworks.add(userWalletId, networkId)
addedNetworks.cancelPrevChangeIfExist(
userWalletId = userWalletId,
networkId = networkId,
)
}
/** Remove all networks by [userWalletId] */
fun removeAllChanges(userWalletId: UserWalletId) {
addedNetworks.update {
it.toMutableMap().apply { remove(userWalletId) }
}
removedNetworks.update {
it.toMutableMap().apply { remove(userWalletId) }
}
}
fun associateWithToggle(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
addToPortfolioData: AddToPortfolioData,
): Map<TokenMarketInfo.Network, Boolean> {
val filteredNetworks = filterAvailableNetworksForWalletUseCase(
userWalletId = userWalletId,
networks = addToPortfolioData.availableNetworks.orEmpty(),
)
// Use user choice or check already added networks
return filteredNetworks.associateWith { availableNetwork ->
val isAddedByUser = addToPortfolioData.addedNetworks[userWalletId]?.contains(availableNetwork)
if (isAddedByUser == true) return@associateWith true
val isRemovedByUser = addToPortfolioData.removedNetworks[userWalletId]?.contains(availableNetwork)
if (isRemovedByUser == true) return@associateWith false
val isAddedBefore = alreadyAddedNetworkIds.any { it == availableNetwork.networkId }
isAddedBefore
}
}
private fun MutableStateFlow<WalletsWithNetworks>.cancelPrevChangeIfExist(
userWalletId: UserWalletId,
networkId: String,
) {
if (value[userWalletId].orEmpty().any { it.networkId == networkId }) remove(userWalletId, networkId)
}
private fun MutableStateFlow<WalletsWithNetworks>.add(userWalletId: UserWalletId, networkId: String) {
change(userWalletId = userWalletId, networkId = networkId, isAddAction = true)
}
private fun MutableStateFlow<WalletsWithNetworks>.remove(userWalletId: UserWalletId, networkId: String) {
change(userWalletId = userWalletId, networkId = networkId, isAddAction = false)
}
private fun MutableStateFlow<WalletsWithNetworks>.change(
userWalletId: UserWalletId,
networkId: String,
isAddAction: Boolean,
) {
val network = availableNetworks.value.orEmpty().firstOrNull { it.networkId == networkId }
if (network == null) {
Timber.d(
"Network [$networkId] doesn't contain in available networks [%s]",
availableNetworks.value?.joinToString { it.networkId },
)
return
}
update { walletsWithNetworks ->
walletsWithNetworks.toMutableMap().apply {
this[userWalletId] = if (isAddAction) {
this[userWalletId].orEmpty() + network
} else {
this[userWalletId].orEmpty() - network
}
}
}
}
/**
* Add to portfolio data
*
* @property availableNetworks available networks that user can add to portfolio
* @property addedNetworks networks that user toggled on, but it might have already been added to the wallet
* @property removedNetworks networks that user toggled off, but it might haven't been added to the wallet
*
* Example for [addedNetworks] and [removedNetworks]. This lists will include new networks when user just
* toggle it. But when we will save user changes, we will check what tokens have already been added or
* haven't been added to the wallet. See [getAddedNetworks] and [getRemovedNetworks]
*/
data class AddToPortfolioData(
val availableNetworks: Set<TokenMarketInfo.Network>?,
val addedNetworks: WalletsWithNetworks,
val removedNetworks: WalletsWithNetworks,
) {
fun isUserAddedNetworks(userWalletId: UserWalletId): Boolean {
return addedNetworks[userWalletId].orEmpty().isNotEmpty()
}
fun isUserChangedNetworks(userWalletId: UserWalletId): Boolean {
return addedNetworks[userWalletId].orEmpty().isNotEmpty() ||
removedNetworks[userWalletId].orEmpty().isNotEmpty()
}
/** Get new networks that user [userWalletId] added using [alreadyAddedNetworkIds] */
fun getAddedNetworks(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
): Set<TokenMarketInfo.Network> {
val addedNetworksByUser = addedNetworks[userWalletId].orEmpty()
return addedNetworksByUser.map { it.networkId }
.minus(alreadyAddedNetworkIds)
.mapNotNull { networkId -> addedNetworksByUser.firstOrNull { it.networkId == networkId } }
.toSet()
}
/** Get networks that user [userWalletId] removed using [alreadyAddedNetworkIds] */
fun getRemovedNetworks(
userWalletId: UserWalletId,
alreadyAddedNetworkIds: Set<String>,
): Set<TokenMarketInfo.Network> {
val removedNetworksByUser = removedNetworks[userWalletId].orEmpty()
return alreadyAddedNetworkIds
.minus(removedNetworksByUser.map { it.networkId }.toSet())
.mapNotNull { networkId -> removedNetworksByUser.firstOrNull { it.networkId == networkId } }
.toSet()
}
}
}

View file

@ -5,7 +5,6 @@ import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
@ -45,7 +44,7 @@ import kotlinx.coroutines.flow.*
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongParameterList")
internal class NewMarketsPortfolioDelegate @AssistedInject constructor(
internal class MarketsPortfolioDelegate @AssistedInject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val allAccountSupplier: MultiAccountStatusListSupplier,
@ -106,10 +105,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor(
private fun addFirstTokenFlow(): Flow<MyPortfolioUM> = buttonState.map { state ->
when (state) {
MyPortfolioUM.Tokens.AddButtonState.Loading -> MyPortfolioUM.Loading
MyPortfolioUM.Tokens.AddButtonState.Available -> MyPortfolioUM.AddFirstToken(
onAddClick = onAddClick,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
)
MyPortfolioUM.Tokens.AddButtonState.Available -> MyPortfolioUM.AddFirstToken(onAddClick = onAddClick)
MyPortfolioUM.Tokens.AddButtonState.Unavailable -> MyPortfolioUM.Unavailable
}
}
@ -314,7 +310,7 @@ internal class NewMarketsPortfolioDelegate @AssistedInject constructor(
tokenActionsHandler: TokenActionsHandler,
buttonState: Flow<MyPortfolioUM.Tokens.AddButtonState>,
onAddClick: () -> Unit,
): NewMarketsPortfolioDelegate
): MarketsPortfolioDelegate
}
}

View file

@ -5,91 +5,56 @@ import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
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.components.rows.model.BlockchainRowUM
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.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.markets.SaveMarketTokensUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.currency.CryptoCurrency
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.transaction.usecase.ReceiveAddressesFactory
import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioDataLoader
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import com.tangem.features.feed.impl.R
import com.tangem.features.wallet.utils.UserWalletImageFetcher
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.operations.attestation.ArtworkSize
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager as NewAddToPortfolioManager
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class MarketsPortfolioModel @Inject constructor(
paramsContainer: ParamsContainer,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
tokenActionsIntentsFactory: TokenActionsHandler.Factory,
override val dispatchers: CoroutineDispatcherProvider,
private val messageSender: UiMessageSender,
private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase,
private val getSelectedWalletUseCase: GetSelectedWalletUseCase,
private val portfolioDataLoader: PortfolioDataLoader,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
private val saveMarketTokensUseCase: SaveMarketTokensUseCase,
private val addToPortfolioManager: AddToPortfolioManager,
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletImageFetcher: UserWalletImageFetcher,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val marketsPortfolioDelegateFactory: MarketsPortfolioDelegate.Factory,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val tokenActionsHandlerFactory: TokenActionsHandler.Factory,
private val receiveAddressesFactory: ReceiveAddressesFactory,
accountsFeatureToggles: AccountsFeatureToggles,
newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory,
newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory,
private val analyticsEventHandler: AnalyticsEventHandler,
override val dispatchers: CoroutineDispatcherProvider,
) : Model() {
private val _state: MutableStateFlow<MyPortfolioUM> = MutableStateFlow(value = MyPortfolioUM.Loading)
val state: StateFlow<MyPortfolioUM> get() = _state
val state: StateFlow<MyPortfolioUM>
field = MutableStateFlow<MyPortfolioUM>(value = MyPortfolioUM.Loading)
private val params = paramsContainer.require<MarketsPortfolioComponent.Params>()
private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder(
tokenSymbol = params.token.symbol,
source = params.analyticsParams?.source,
)
val newAddToPortfolioManager: NewAddToPortfolioManager?
val newMarketsPortfolioDelegate: NewMarketsPortfolioDelegate?
private val currentAppCurrency = createAppCurrencyFlow()
private val tokenActionsHandler = createTokenActionsHandler()
/** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */
private val selectedMultiWalletIdFlow = MutableStateFlow<UserWalletId?>(value = null)
private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel())
val addToPortfolioManager: AddToPortfolioManager = createAddToPortfolioManager()
private val marketsPortfolioDelegate: MarketsPortfolioDelegate = createMarketsPortfolioDelegate()
val bottomSheetNavigation: SlotNavigation<MarketsPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
@ -97,321 +62,78 @@ internal class MarketsPortfolioModel @Inject constructor(
override fun onSuccess(addedToken: CryptoCurrency) = bottomSheetNavigation.dismiss()
}
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val tokenActionsHandler = tokenActionsIntentsFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
onHandleQuickAction = { handledAction ->
analyticsEventHandler.send(
analyticsEventBuilder.quickActionClick(
actionUM = handledAction.action,
blockchainName = handledAction
.cryptoCurrencyData
.status
.currency
.network
.name,
),
)
configureReceiveAddresses(handledAction)
},
)
private val factory = MyPortfolioUMFactory(
onAddClick = {
onAddToPortfolioBSVisibilityChange(isShow = true)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioClicked(),
)
},
addToPortfolioBSContentUMFactory = AddToPortfolioBSContentUMFactory(
addToPortfolioManager = addToPortfolioManager,
token = params.token,
onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange,
onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange,
onNetworkSwitchClick = ::onNetworkSwitchClick,
onAnotherWalletSelect = { walletId ->
onWalletSelect(walletId)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioWalletChanged(),
)
},
onContinueClick = { selectedWalletId, addedNetworks ->
onContinueClick(selectedWalletId, addedNetworks)
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.addToPortfolioContinue(
blockchainNames = addedNetworks.mapNotNull {
BlockchainUtils.getNetworkInfo(it.networkId)?.name
},
),
)
},
),
currentState = Provider { _state.value },
tokenActionsHandler = tokenActionsHandler,
updateTokens = { updateBlock ->
updateTokensState { state ->
state.copy(tokens = updateBlock(state.tokens))
}
},
)
init {
if (accountsFeatureToggles.isFeatureEnabled) {
newAddToPortfolioManager = newAddToPortfolioManagerFactory
.create(
modelScope,
params.token,
params.analyticsParams?.source?.let { NewAddToPortfolioManager.AnalyticsParams(it) },
)
newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create(
scope = modelScope,
token = params.token,
tokenActionsHandler = tokenActionsHandler,
buttonState = newAddToPortfolioManager.state.map { state ->
when (state) {
is NewAddToPortfolioManager.State.AvailableToAdd -> {
MyPortfolioUM.Tokens.AddButtonState.Available
}
NewAddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading
NewAddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable
}
},
onAddClick = {
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioClicked())
bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio)
},
)
newMarketsPortfolioDelegate.combineData()
.onEach { _state.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
} else {
newAddToPortfolioManager = null
newMarketsPortfolioDelegate = null
// Subscribe on selected wallet flow to support actual selected wallet
subscribeOnSelectedMultiWalletUpdates()
subscribeOnStateUpdates()
}
marketsPortfolioDelegate.combineData()
.onEach { state.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
addToPortfolioManager.setAvailableNetworks(networks)
newAddToPortfolioManager?.setTokenNetworks(networks)
newMarketsPortfolioDelegate?.setTokenNetworks(networks)
addToPortfolioManager.setTokenNetworks(networks)
marketsPortfolioDelegate.setTokenNetworks(networks)
}
fun setNoNetworksAvailable() {
addToPortfolioManager.setAvailableNetworks(emptyList())
newAddToPortfolioManager?.setTokenNetworks(emptyList())
newMarketsPortfolioDelegate?.setTokenNetworks(emptyList())
addToPortfolioManager.setTokenNetworks(emptyList())
marketsPortfolioDelegate.setTokenNetworks(emptyList())
}
private fun subscribeOnSelectedMultiWalletUpdates() {
getSelectedWalletUseCase()
.getOrElse { e ->
Timber.e("Failed to load selected wallet: $e")
error("Failed to load selected wallet")
}
.onEach { userWallet ->
selectedMultiWalletIdFlow.value = userWallet.takeIf { it.isMultiCurrency }?.walletId
}
.launchIn(modelScope)
}
private fun subscribeOnStateUpdates() {
combine(
flow = loadPortfolioDataWithArtworks(params.token.id),
flow2 = getPortfolioUIDataFlow(),
transform = { pair, portfolioUIData ->
val (portfolioData, artworks) = pair
factory.create(portfolioData, portfolioUIData, artworks)
},
)
.onEach { _state.value = it }
.launchIn(modelScope)
}
private fun loadPortfolioDataWithArtworks(
currencyRawId: CryptoCurrency.RawID,
): Flow<Pair<PortfolioData, Map<UserWalletId, UserWalletItemUM.ImageState>>> {
val wallets = Channel<Set<UserWallet>>()
val portfolioFlow = portfolioDataLoader
.load(currencyRawId)
.onEach { wallets.trySend(it.walletsWithCurrencies.keys) }
val artworksFlow = wallets.receiveAsFlow()
.distinctUntilChanged()
.flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) }
return combine(
flow = portfolioFlow,
flow2 = artworksFlow,
) { portfolioData, artworks -> portfolioData to artworks }
}
private fun getPortfolioUIDataFlow(): Flow<PortfolioUIData> {
return combine(
flow = portfolioBSVisibilityModelFlow,
flow2 = selectedMultiWalletIdFlow,
flow3 = addToPortfolioManager.getAddToPortfolioData(),
transform = { portfolioBSVisibilityModel, selectedWalletId, addToPortfolioData ->
PortfolioUIData(
portfolioBSVisibilityModel = portfolioBSVisibilityModel,
selectedWalletId = selectedWalletId,
addToPortfolioData = addToPortfolioData,
isNeededColdWalletInteraction = isNeededColdWalletInteraction(selectedWalletId, addToPortfolioData),
)
},
private fun createAddToPortfolioManager(): AddToPortfolioManager {
return addToPortfolioManagerFactory.create(
scope = modelScope,
token = params.token,
analyticsParams = params.analyticsParams?.source?.let { AddToPortfolioManager.AnalyticsParams(it) },
)
}
private suspend fun isNeededColdWalletInteraction(
selectedWalletId: UserWalletId?,
addToPortfolioData: AddToPortfolioManager.AddToPortfolioData,
): Boolean {
return if (selectedWalletId != null) {
coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = selectedWalletId,
networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty()
.associate { it.networkId to null },
)
} else {
false
}
}
private fun onNetworkSwitchClick(blockchainRowUM: BlockchainRowUM, isChecked: Boolean) {
val selectedWalletId = selectedMultiWalletIdFlow.value
if (selectedWalletId == null) {
Timber.e("Impossible to switch network when selected wallet is null")
return
}
if (isChecked) {
modelScope.launch {
val unsupportedState = checkCurrencyUnsupportedState(
userWalletId = selectedWalletId,
rawNetworkId = blockchainRowUM.id,
isMainNetwork = blockchainRowUM.isMainNetwork,
)
if (unsupportedState != null) {
showUnsupportedWarning(unsupportedState)
} else {
addToPortfolioManager.addNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id)
private fun createMarketsPortfolioDelegate(): MarketsPortfolioDelegate {
return marketsPortfolioDelegateFactory.create(
scope = modelScope,
token = params.token,
tokenActionsHandler = tokenActionsHandler,
buttonState = addToPortfolioManager.state.map { state ->
when (state) {
is AddToPortfolioManager.State.AvailableToAdd -> {
MyPortfolioUM.Tokens.AddButtonState.Available
}
AddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading
AddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable
}
}
} else {
addToPortfolioManager.removeNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id)
}
}
private suspend fun checkCurrencyUnsupportedState(
userWalletId: UserWalletId,
rawNetworkId: String,
isMainNetwork: Boolean,
): CurrencyUnsupportedState? {
return checkCurrencyUnsupportedUseCase(
userWalletId = userWalletId,
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse { throwable ->
Timber.e(
throwable,
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
)
val message = SnackbarMessage(
message = throwable.localizedMessage
?.let(::stringReference)
?: resourceReference(R.string.common_error),
)
messageSender.send(message)
null
}
}
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_curve_message,
formatArgs = wrappedList(unsupportedState.networkName),
)
},
onAddClick = {
analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioClicked())
bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio)
},
)
messageSender.send(message)
}
private fun onWalletSelect(userWalletId: UserWalletId) {
selectedMultiWalletIdFlow.update { prevUserWalletId ->
prevUserWalletId?.let(addToPortfolioManager::removeAllChanges)
userWalletId
}
}
private fun onContinueClick(userWalletId: UserWalletId, addedNetworks: Set<TokenMarketInfo.Network>) {
modelScope.launch {
saveMarketTokensUseCase(
userWalletId = userWalletId,
tokenMarketParams = params.token,
addedNetworks = addedNetworks,
removedNetworks = emptySet(),
private fun createAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
onAddToPortfolioBSVisibilityChange(isShow = false)
addToPortfolioManager.removeAllChanges(userWalletId)
}
}
private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) {
portfolioBSVisibilityModelFlow.update {
it.copy(isAddToPortfolioBSVisible = isShow, isWalletSelectorBSVisible = false)
}
}
private fun onWalletSelectorVisibilityChange(isShow: Boolean) {
portfolioBSVisibilityModelFlow.update {
it.copy(isAddToPortfolioBSVisible = true, isWalletSelectorBSVisible = isShow)
}
}
private fun updateTokensState(block: (MyPortfolioUM.Tokens) -> MyPortfolioUM) {
_state.update { stateToUpdate ->
val tokensState = stateToUpdate as? MyPortfolioUM.Tokens ?: return@update stateToUpdate
block(tokensState)
}
private fun createTokenActionsHandler(): TokenActionsHandler {
return tokenActionsHandlerFactory.create(
currentAppCurrency = Provider { currentAppCurrency.value },
onHandleQuickAction = { handledAction ->
val currency = handledAction.cryptoCurrencyData.status.currency
analyticsEventHandler.send(
analyticsEventBuilder.quickActionClick(
actionUM = handledAction.action,
blockchainName = currency.network.name,
),
)
configureReceiveAddresses(handledAction)
},
)
}
private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) {

View file

@ -1,153 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.markets.TokenMarketInfo
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.models.wallet.isMultiCurrency
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
/**
* Factory for creating [MyPortfolioUM]
*
* @property onAddClick callback when user wants to add new token
* @property addToPortfolioBSContentUMFactory factory for creating add to portfolio bottom sheet content
* @property tokenActionsHandler token actions handler
* @property currentState current state provider
* @property updateTokens callback for updating tokens
*
[REDACTED_AUTHOR]
*/
internal class MyPortfolioUMFactory(
private val onAddClick: () -> Unit,
private val addToPortfolioBSContentUMFactory: AddToPortfolioBSContentUMFactory,
private val tokenActionsHandler: TokenActionsHandler,
private val currentState: Provider<MyPortfolioUM>,
private val updateTokens: ((ImmutableList<PortfolioTokenUM>) -> ImmutableList<PortfolioTokenUM>) -> Unit,
) {
fun create(
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): MyPortfolioUM {
val addToPortfolioData = portfolioUIData.addToPortfolioData
val isOnlyUnavailableNetworks = addToPortfolioData.availableNetworks?.isEmpty() == true
if (isOnlyUnavailableNetworks) return MyPortfolioUM.Unavailable
val walletsWithCurrencies = if (addToPortfolioData.availableNetworks == null) {
portfolioData.walletsWithCurrencies
} else {
portfolioData.walletsWithCurrencies.filterAvailableNetworks(networks = addToPortfolioData.availableNetworks)
}
val isPortfolioEmpty = walletsWithCurrencies.flatMap { it.value }.isEmpty()
if (isPortfolioEmpty) {
val hasMultiWallets = walletsWithCurrencies.filterKeys(UserWallet::isMultiCurrency).isNotEmpty()
return if (hasMultiWallets) {
MyPortfolioUM.AddFirstToken(
addToPortfolioBSConfig = createAddToPortfolioBSConfig(
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
artworks = artworks,
),
onAddClick = onAddClick,
)
} else {
MyPortfolioUM.UnavailableForWallet
}
}
return TokensPortfolioUMConverter(
appCurrency = portfolioData.appCurrency,
isBalanceHidden = portfolioData.isBalanceHidden,
addButtonState = walletsWithCurrencies.getAddButtonState(
availableNetworks = addToPortfolioData.availableNetworks,
),
bsConfig = createAddToPortfolioBSConfig(
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
artworks = artworks,
),
onAddClick = onAddClick,
quickActionsIntents = tokenActionsHandler,
currentState = currentState,
updateTokens = updateTokens,
)
.convert(walletsWithCurrencies)
}
private fun createAddToPortfolioBSConfig(
portfolioData: PortfolioData,
portfolioUIData: PortfolioUIData,
artworks: Map<UserWalletId, UserWalletItemUM.ImageState>,
): TangemBottomSheetConfig {
val selectedWallet = portfolioData.walletsWithCurrencies.keys
.firstOrNull { it.walletId == portfolioUIData.selectedWalletId }
?: portfolioData.walletsWithCurrencies.keys.firstOrNull { it.isMultiCurrency }
val availableNetworks = portfolioUIData.addToPortfolioData.availableNetworks.orEmpty()
val alreadyAddedNetworks = portfolioData.walletsWithCurrencies
.filterAvailableNetworks(availableNetworks)[selectedWallet]
?.filter { !it.status.currency.isCustom }
?.map { it.status.currency.network.backendId }
?.toSet()
return addToPortfolioBSContentUMFactory.create(
currentState = currentState().addToPortfolioBSConfig,
portfolioData = portfolioData,
portfolioUIData = portfolioUIData,
selectedWallet = selectedWallet,
alreadyAddedNetworks = alreadyAddedNetworks,
artworks = artworks,
)
}
private fun Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>.getAddButtonState(
availableNetworks: Set<TokenMarketInfo.Network>?,
): MyPortfolioUM.Tokens.AddButtonState {
if (availableNetworks == null) return MyPortfolioUM.Tokens.AddButtonState.Loading
val networkIds = availableNetworks.map { it.networkId }
val isAllAvailableNetworksAdded = this
// User can add currencies only in multi-currency wallets
.filterKeys(UserWallet::isMultiCurrency)
.mapValues { entry -> entry.value.map { it.status.currency.network.backendId } }
// Each wallets contains all available networks?
.all { it.value.containsAll(networkIds) }
return if (isAllAvailableNetworksAdded) {
MyPortfolioUM.Tokens.AddButtonState.Unavailable
} else {
MyPortfolioUM.Tokens.AddButtonState.Available
}
}
/** Filter map values by available networks [networks] */
private fun Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>.filterAvailableNetworks(
networks: Set<TokenMarketInfo.Network>,
): Map<UserWallet, List<PortfolioData.CryptoCurrencyData>> {
return mapValues { entry -> entry.value.filterAvailableNetworks(networks) }
}
/** Filter list of [CryptoCurrencyStatus] by available networks [networks] */
private fun List<PortfolioData.CryptoCurrencyData>.filterAvailableNetworks(
networks: Set<TokenMarketInfo.Network>,
): List<PortfolioData.CryptoCurrencyData> {
val networkIds = networks.map(TokenMarketInfo.Network::networkId)
return mapNotNull { cryptoCurrencyData ->
cryptoCurrencyData.takeIf { networkIds.contains(it.status.currency.network.backendId) }
}
}
}

View file

@ -1,14 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
/**
* Model for portfolio bottom sheet visibility
*
* @property isAddToPortfolioBSVisible visibility of add to portfolio bottom sheet
* @property isWalletSelectorBSVisible visibility of wallet selector bottom sheet
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioBSVisibilityModel(
val isAddToPortfolioBSVisible: Boolean = false,
val isWalletSelectorBSVisible: Boolean = false,
)

View file

@ -1,20 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.domain.models.wallet.UserWalletId
/**
* Portfolio UI data. Combined data from all UI flows that required to setup portfolio
*
* @property portfolioBSVisibilityModel portfolio bottom sheet visibility model
* @property selectedWalletId selected wallet id
* @property addToPortfolioData add to portfolio data
* @property isNeededColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet
*
[REDACTED_AUTHOR]
*/
internal data class PortfolioUIData(
val portfolioBSVisibilityModel: PortfolioBSVisibilityModel,
val selectedWalletId: UserWalletId?,
val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData,
val isNeededColdWalletInteraction: Boolean,
)

View file

@ -1,37 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from [TokenMarketParams] to [SelectNetworkUM]
*
* @property networksWithToggle map of networks with toggles
* @property alreadyAddedNetworks already added networks
* @property onNetworkSwitchClick callback is called when network switch is clicked
*
[REDACTED_AUTHOR]
*/
internal class SelectNetworkUMConverter(
private val networksWithToggle: Map<TokenMarketInfo.Network, Boolean>,
private val alreadyAddedNetworks: Set<String>,
private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit,
) : Converter<TokenMarketParams, SelectNetworkUM> {
override fun convert(value: TokenMarketParams): SelectNetworkUM {
return SelectNetworkUM(
tokenId = value.id.value,
iconUrl = value.imageUrl,
tokenName = value.name,
tokenCurrencySymbol = value.symbol,
networks = BlockchainRowUMConverter(alreadyAddedNetworks)
.convertList(networksWithToggle.toList())
.toImmutableList(),
onNetworkSwitchClick = { um, isChecked -> onNetworkSwitchClick(um, isChecked) },
)
}
}

View file

@ -2,9 +2,12 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
@ -12,9 +15,8 @@ import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
@ -30,7 +32,9 @@ internal class TokenActionsHandler @AssistedInject constructor(
private val router: Router,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val reduxStateHolder: ReduxStateHolder,
private val getOfframpUrlUseCase: GetOfframpUrlUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
@ -104,12 +108,13 @@ internal class TokenActionsHandler @AssistedInject constructor(
}
private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {
reduxStateHolder.dispatch(
TradeCryptoAction.Sell(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
),
)
getOfframpUrlUseCase(
cryptoCurrencyStatus = cryptoCurrencyData.status,
appCurrencyCode = currentAppCurrency().code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) {

View file

@ -1,112 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.model
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Converter from [Map] of [UserWallet] and [CryptoCurrencyStatus] to [MyPortfolioUM.Tokens]
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
internal class TokensPortfolioUMConverter(
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val addButtonState: MyPortfolioUM.Tokens.AddButtonState,
private val bsConfig: TangemBottomSheetConfig,
private val onAddClick: () -> Unit,
private val quickActionsIntents: TokenActionsHandler,
private val currentState: Provider<MyPortfolioUM>,
private val updateTokens: ((ImmutableList<PortfolioTokenUM>) -> ImmutableList<PortfolioTokenUM>) -> Unit,
) : Converter<Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>, MyPortfolioUM.Tokens> {
override fun convert(value: Map<UserWallet, List<PortfolioData.CryptoCurrencyData>>): MyPortfolioUM.Tokens {
val currentTokensState = currentState() as? MyPortfolioUM.Tokens
return MyPortfolioUM.Tokens(
tokens = value
.flatMap { entry -> entry.value }
.map { cryptoData ->
PortfolioTokenUMConverter(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
onTokenItemClick = { toggleQuickActions(cryptoData) },
tokenActionsHandler = quickActionsIntents,
).convert(value = cryptoData) to cryptoData
}
.setQuickActionsVisibility(currentState = currentTokensState)
.toImmutableList(),
buttonState = addButtonState,
addToPortfolioBSConfig = bsConfig,
onAddClick = onAddClick,
)
}
private fun List<Pair<PortfolioTokenUM, PortfolioData.CryptoCurrencyData>>.setQuickActionsVisibility(
currentState: MyPortfolioUM.Tokens?,
): List<PortfolioTokenUM> {
return when {
// if there is only one token and it has empty balance, show quick actions for it
currentState == null && this.size == 1 && isEmptyBalance(this.first().second) -> {
this.map { (token, _) ->
token.copy(isQuickActionsShown = true)
}
}
// if there is no previous state, hide quick actions for all tokens
currentState == null -> {
this.map { (token, _) ->
token.copy(isQuickActionsShown = false)
}
}
else -> {
val previousList = currentState.tokens
// otherwise, keep previous state
this.map { (token, _) ->
token.copy(
isQuickActionsShown = previousList
.firstOrNull { it.matchWith(token) }
?.isQuickActionsShown == true,
)
}
}
}
}
private fun isEmptyBalance(cryptoData: PortfolioData.CryptoCurrencyData): Boolean {
return cryptoData.status.value.amount?.isZero() == true
}
private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) {
updateTokens { tokenList ->
tokenList.map { portfolioTokenUM ->
portfolioTokenUM.copy(
isQuickActionsShown = if (portfolioTokenUM.matchWith(cryptoData)) {
!portfolioTokenUM.isQuickActionsShown
} else {
false
},
)
}.toImmutableList()
}
}
private fun PortfolioTokenUM.matchWith(token: PortfolioTokenUM): Boolean {
return this.walletId == token.walletId && this.tokenItemState.id == token.tokenItemState.id
}
private fun PortfolioTokenUM.matchWith(cryptoData: PortfolioData.CryptoCurrencyData): Boolean {
return this.walletId == cryptoData.userWallet.walletId &&
this.tokenItemState.id == cryptoData.status.currency.id.value
}
}

View file

@ -1,383 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
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.util.fastForEachIndexed
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.rows.ArrowRow
import com.tangem.core.ui.components.rows.BlockchainRow
import com.tangem.core.ui.extensions.resourceReference
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.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM
import com.tangem.features.feed.impl.R
import kotlinx.coroutines.delay
@Composable
internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<AddToPortfolioBSContentUM>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
addBottomInsets = false,
titleText = resourceReference(R.string.common_add_to_portfolio),
) { contentUM ->
Content(
modifier = Modifier.fillMaxWidth(),
state = contentUM,
)
WalletSelectorBottomSheet(contentUM.walletSelectorConfig)
}
}
@Composable
private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modifier) {
var continueButtonAreaHeight by remember { mutableIntStateOf(0) }
val density = LocalDensity.current
val scrollState = rememberScrollState()
Box(modifier = modifier) {
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
if (state.isWalletBlockVisible) {
UserWalletItem(
state = state.selectedWallet,
blockColors = TangemBlockCardColors.copy(
containerColor = TangemTheme.colors.background.action,
disabledContainerColor = TangemTheme.colors.background.action,
),
)
SpacerH12()
}
NetworkSelection(
modifier = Modifier.fillMaxWidth(),
state = state.selectNetworkUM,
)
SpacerH12()
AnimatedVisibility(
visible = state.isScanCardNotificationVisible,
modifier = Modifier.fillMaxWidth(),
) {
Column {
ScanWalletWarning(modifier = Modifier.fillMaxWidth())
SpacerH12()
}
// Scroll to the bottom when the notification appears and the scroll is at the bottom
LaunchedEffect(Unit) {
if (scrollState.canScrollForward.not()) {
delay(timeMillis = 500)
scrollState.animateScrollTo(scrollState.maxValue)
}
}
}
SpacerH(with(density) { continueButtonAreaHeight.toDp() })
}
AnimatedVisibility(
visible = scrollState.canScrollForward,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
BottomFade(Modifier.align(Alignment.BottomCenter))
}
ContinueButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.onGloballyPositioned {
continueButtonAreaHeight = it.size.height
},
enabled = state.isContinueButtonEnabled,
isTangemIconVisible = state.isScanCardNotificationVisible,
onClick = state.onContinueButtonClick,
)
}
}
@Composable
private fun ContinueButton(
enabled: Boolean,
isTangemIconVisible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemButton(
enabled = enabled,
modifier = modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
)
.navigationBarsPadding()
.fillMaxWidth(),
text = stringResourceSafe(R.string.common_continue),
icon = if (enabled && isTangemIconVisible) {
TangemButtonIconPosition.End(R.drawable.ic_tangem_24)
} else {
TangemButtonIconPosition.None
},
showProgress = false,
size = TangemButtonSize.Default,
colors = TangemButtonsDefaults.primaryButtonColors,
textStyle = TangemTheme.typography.subtitle1,
onClick = onClick,
animateContentChange = true,
)
}
@Suppress("LongMethod")
@Composable
private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifier) {
val hapticManager = LocalHapticManager.current
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(R.string.markets_select_network),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
},
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens.spacing14),
verticalAlignment = Alignment.CenterVertically,
) {
CoinIcon(
modifier = Modifier.size(TangemTheme.dimens.size36),
url = state.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
SpacerW12()
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.weight(1f, fill = false)
.alignByBaseline(),
text = state.tokenName,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW6()
Text(
modifier = Modifier
.align(Alignment.CenterVertically)
.alignByBaseline(),
text = state.tokenCurrencySymbol,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Visible,
maxLines = 1,
)
}
state.networks.fastForEachIndexed { index, network ->
ArrowRow(
isLastItem = index == state.networks.lastIndex,
content = {
BlockchainRow(
modifier = Modifier.padding(end = TangemTheme.dimens.spacing4),
model = network,
action = {
TangemSwitch(
checked = network.isSelected,
checkedColor = if (network.isEnabled) {
TangemTheme.colors.control.checked
} else {
TangemTheme.colors.icon.inactive
},
onCheckedChange = { checked ->
if (checked) {
hapticManager.perform(TangemHapticEffect.View.ToggleOn)
} else {
hapticManager.perform(TangemHapticEffect.View.ToggleOff)
}
state.onNetworkSwitchClick(network, checked)
},
enabled = network.isEnabled,
)
},
)
},
)
}
}
}
}
@Composable
private fun ScanWalletWarning(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(
color = TangemTheme.colors.button.disabled,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10),
) {
Icon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size20),
imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
)
Text(
text = stringResourceSafe(R.string.markets_generate_addresses_notification),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview {
AddToPortfolioBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
content = content,
onDismissRequest = {},
),
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun PreviewContent(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview {
Content(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.fillMaxWidth(),
state = content,
)
}
}
@Composable
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun PreviewContentRtl(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview(rtl = true) {
Content(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.fillMaxWidth(),
state = content,
)
}
}
// For on device testing
@Composable
@Preview
private fun PreviewContentTestOnDevice(
@PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM,
) {
TangemThemePreview(
alwaysShowBottomSheets = false,
) {
var isShow by remember { mutableStateOf(false) }
var contentState by remember {
mutableStateOf(content)
}
LaunchedEffect(Unit) {
contentState = content.copy(
onContinueButtonClick = {
contentState = contentState.copy(
isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible,
)
},
isContinueButtonEnabled = true,
selectedWallet = content.selectedWallet.copy(
onClick = {
contentState = contentState.copy(
isContinueButtonEnabled = !contentState.isContinueButtonEnabled,
)
},
),
)
}
AddToPortfolioBottomSheet(
config = TangemBottomSheetConfig(
isShown = isShow,
content = contentState,
onDismissRequest = { isShow = false },
),
)
Button(
onClick = { isShow = !isShow },
) {
Text(text = "Toggle")
}
}
}

View file

@ -70,11 +70,6 @@ internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) {
is MyPortfolioUM.Content -> PortfolioList(state = state)
}
}
val bsConfig = state.addToPortfolioBSConfig
if (bsConfig != null) {
AddToPortfolioBottomSheet(config = bsConfig)
}
}
@Composable

View file

@ -1,86 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.SimpleSettingsRow
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM
import kotlinx.collections.immutable.toImmutableList
@Composable
fun TokenActionsBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<TokenActionsBSContentUM>(
config = config,
title = { content ->
TangemBottomSheetTitle(content.title)
},
containerColor = TangemTheme.colors.background.tertiary,
content = { Content(it) },
)
}
@Composable
private fun Content(content: TokenActionsBSContentUM) {
Column(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) {
content.actions.forEachIndexed { index, action ->
Box(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = content.actions.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action),
) {
SimpleSettingsRow(
title = action.text.resolveReference(),
icon = action.iconRes,
redesign = true,
onItemsClick = { content.onActionClick(action) },
)
}
}
}
}
@Preview(widthDp = 360, heightDp = 640)
@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview(
alwaysShowBottomSheets = true,
) {
Box(Modifier.background(TangemTheme.colors.background.secondary)) {
TokenActionsBottomSheet(
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = TokenActionsBSContentUM(
title = "Wallet 1",
actions = TokenActionsBSContentUM.Action.entries.toImmutableList(),
onActionClick = {},
),
),
)
}
}
}

View file

@ -1,145 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.userwallet.UserWalletItem
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM
import com.tangem.features.feed.impl.R
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun WalletSelectorBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet<WalletSelectorBSContentUM>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
addBottomInsets = false,
title = { content ->
TangemTopAppBar(
title = resourceReference(R.string.common_choose_wallet),
titleAlignment = Alignment.CenterHorizontally,
startButton = TopAppBarButtonUM.Back(content.onBack),
height = TangemTopAppBarHeight.BOTTOM_SHEET,
)
},
) { content ->
Content(
modifier = Modifier
.fillMaxSize()
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing8,
),
state = content,
)
}
}
@Composable
private fun Content(state: WalletSelectorBSContentUM, modifier: Modifier = Modifier) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
) {
BlockCard(
modifier = Modifier.fillMaxSize(),
colors = TangemBlockCardColors.copy(
containerColor = TangemTheme.colors.background.action,
disabledContainerColor = TangemTheme.colors.background.action,
),
) {
state.userWallets.forEach { state ->
key(state.id) {
UserWalletItem(
modifier = Modifier.fillMaxWidth(),
blockColors = TangemBlockCardColors.copy(
containerColor = TangemTheme.colors.background.action,
disabledContainerColor = TangemTheme.colors.background.action,
),
state = state,
)
}
}
}
SpacerH(bottomBarHeight)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
WalletSelectorBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = WalletSelectorBSContentUM(
userWallets = persistentListOf(
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.None,
),
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.Checkmark,
),
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.None,
),
),
onBack = {},
),
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewContent() {
TangemThemePreview {
Content(
state = WalletSelectorBSContentUM(
userWallets = persistentListOf(
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.None,
),
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.Checkmark,
),
PreviewAddToPortfolioBSContentProvider().userWallet.copy(
endIcon = UserWalletItemUM.EndIcon.None,
),
),
onBack = {},
),
)
}
}

View file

@ -1,86 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM
import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM
import com.tangem.features.feed.impl.R
import kotlinx.collections.immutable.persistentListOf
internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider<AddToPortfolioBSContentUM> {
private val blockchainRow = BlockchainRowUM(
id = "1",
name = "Etherium 3",
type = "TEST",
iconResId = R.drawable.ic_eth_16,
isMainNetwork = false,
isSelected = false,
)
val userWallet = UserWalletItemUM(
id = "1",
name = stringReference("Wallet 1"),
information = UserWalletItemUM.Information.Loaded(TextReference.Str("3 cards")),
balance = UserWalletItemUM.Balance.Loading,
isEnabled = true,
endIcon = UserWalletItemUM.EndIcon.Arrow,
onClick = {},
)
override val values: Sequence<AddToPortfolioBSContentUM>
get() = sequenceOf(
AddToPortfolioBSContentUM(
selectedWallet = userWallet,
selectNetworkUM = SelectNetworkUM(
tokenId = "etherium",
tokenName = "Etherium",
tokenCurrencySymbol = "ETH",
networks = persistentListOf(
blockchainRow.copy(
type = "MAIN",
isMainNetwork = true,
isSelected = true,
),
blockchainRow,
blockchainRow,
),
onNetworkSwitchClick = { _, _ -> },
iconUrl = null,
),
isScanCardNotificationVisible = true,
isWalletBlockVisible = true,
isContinueButtonEnabled = true,
onContinueButtonClick = {},
walletSelectorConfig = TangemBottomSheetConfig.Empty,
),
AddToPortfolioBSContentUM(
selectedWallet = userWallet,
selectNetworkUM = SelectNetworkUM(
tokenId = "etherium",
tokenName = "Etherium Etherium Etherium Etherium",
tokenCurrencySymbol = "ETH",
networks = persistentListOf(
blockchainRow.copy(
type = "MAIN",
isMainNetwork = true,
isSelected = true,
).copy(name = "Etherium Etherium Etherium Etherium"),
*Array(25) { blockchainRow },
),
onNetworkSwitchClick = { _, _ -> },
iconUrl = null,
),
isScanCardNotificationVisible = true,
isWalletBlockVisible = false,
isContinueButtonEnabled = false,
onContinueButtonClick = {},
walletSelectorConfig = TangemBottomSheetConfig.Empty,
),
)
}

View file

@ -3,7 +3,6 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.ui.pre
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIconPreviewData
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
@ -22,25 +21,19 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider<MyPortfol
MyPortfolioUM.Tokens(
tokens = persistentListOf(sampleToken, sampleToken),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Available,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.Tokens(
tokens = persistentListOf(sampleToken, sampleToken.copy(isQuickActionsShown = true)),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Unavailable,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.Tokens(
tokens = persistentListOf(sampleToken.copy(isQuickActionsShown = true), sampleToken),
buttonState = MyPortfolioUM.Tokens.AddButtonState.Loading,
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.AddFirstToken(
addToPortfolioBSConfig = TangemBottomSheetConfig.Empty,
onAddClick = {},
),
MyPortfolioUM.AddFirstToken(onAddClick = {}),
MyPortfolioUM.Content(
items = persistentListOf(
walletPortfolioHeader,

View file

@ -1,15 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
internal data class AddToPortfolioBSContentUM(
val selectedWallet: UserWalletItemUM,
val selectNetworkUM: SelectNetworkUM,
val isWalletBlockVisible: Boolean,
val isScanCardNotificationVisible: Boolean,
val isContinueButtonEnabled: Boolean,
val onContinueButtonClick: () -> Unit,
val walletSelectorConfig: TangemBottomSheetConfig,
) : TangemBottomSheetConfigContent

View file

@ -1,16 +1,12 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class MyPortfolioUM {
abstract val addToPortfolioBSConfig: TangemBottomSheetConfig?
data class Tokens(
override val addToPortfolioBSConfig: TangemBottomSheetConfig,
val tokens: ImmutableList<PortfolioTokenUM>,
val buttonState: AddButtonState,
val onAddClick: () -> Unit,
@ -27,25 +23,15 @@ internal sealed class MyPortfolioUM {
val items: ImmutableList<PortfolioListItem>,
val buttonState: Tokens.AddButtonState,
val onAddClick: () -> Unit,
) : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty
}
) : MyPortfolioUM()
data class AddFirstToken(
override val addToPortfolioBSConfig: TangemBottomSheetConfig,
val onAddClick: () -> Unit,
) : MyPortfolioUM()
data object Loading : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null
}
data object Loading : MyPortfolioUM()
data object Unavailable : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null
}
data object Unavailable : MyPortfolioUM()
data object UnavailableForWallet : MyPortfolioUM() {
override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null
}
data object UnavailableForWallet : MyPortfolioUM()
}

View file

@ -1,13 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import com.tangem.core.ui.components.rows.model.BlockchainRowUM
import kotlinx.collections.immutable.ImmutableList
internal data class SelectNetworkUM(
val tokenId: String,
val iconUrl: String?,
val tokenName: String,
val tokenCurrencySymbol: String,
val networks: ImmutableList<BlockchainRowUM>,
val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit,
)

View file

@ -1,10 +0,0 @@
package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import kotlinx.collections.immutable.ImmutableList
internal data class WalletSelectorBSContentUM(
val userWallets: ImmutableList<UserWalletItemUM>,
val onBack: () -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -7,9 +7,6 @@ internal class DefaultFeedFeatureToggle(
private val featureTogglesManager: FeatureTogglesManager,
) : FeedFeatureToggle {
override val isFeedEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("FEED_ENABLED")
override val isEarnBlockEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("EARN_BLOCK_ENABLED")
}

View file

@ -1,6 +1,6 @@
package com.tangem.features.feed.model.converter
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference

View file

@ -32,12 +32,10 @@ import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConv
import com.tangem.features.feed.model.earn.filters.state.EarnFilterTypeConverter
import com.tangem.features.feed.model.earn.filters.state.EarnFilterTypeUMConverter
import com.tangem.features.feed.model.earn.state.EarnStateController
import com.tangem.features.feed.model.earn.state.transformers.EarnFilterSelectedStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateBestOpportunitiesStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateEarnUMInitialStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.UpdateMostlyUsedStateTransformer
import com.tangem.features.feed.model.earn.state.transformers.*
import com.tangem.features.feed.model.earn.statemanager.EarnListBatchFlowManager
import com.tangem.features.feed.model.earn.statemanager.EarnListStateManager
import com.tangem.features.feed.ui.earn.state.EarnBestOpportunitiesUM
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
@ -69,8 +67,8 @@ internal class EarnModel @Inject constructor(
private val earnNetworks = MutableStateFlow<EarnNetworks>(Either.Right(emptyList()))
private val earnListConfigProvider = Provider {
createEarnTokensListConfig(
selectedTypeFilter = stateController.value.selectedTypeFilter,
selectedNetworkFilter = stateController.value.selectedNetworkFilter,
selectedTypeFilter = stateController.value.earnFilterUM.selectedTypeFilter,
selectedNetworkFilter = stateController.value.earnFilterUM.selectedNetworkFilter,
earnNetworks = earnNetworks.value,
)
}
@ -104,7 +102,6 @@ internal class EarnModel @Inject constructor(
init {
updateInitialState()
fetchEarnNetworks()
fetchTopEarnTokens()
subscribeOnStoredFilters()
subscribeOnNetworks()
subscribeOnBatchFlow()
@ -117,19 +114,22 @@ internal class EarnModel @Inject constructor(
batchFlowManager.initialLoadingError,
batchFlowManager.paginationStatus,
) { items, error, paginationStatus ->
val hasActiveFilters = state.value.selectedTypeFilter != EarnFilterTypeUM.All ||
state.value.selectedNetworkFilter !is EarnFilterNetworkUM.AllNetworks
error?.let(::handleBestOpportunitiesErrorAnalytics)
val hasActiveFilters = state.value.earnFilterUM.selectedTypeFilter != EarnFilterTypeUM.All ||
state.value.earnFilterUM.selectedNetworkFilter !is EarnFilterNetworkUM.AllNetworks
EarnListStateManager.calculateState(
items = items,
error = error,
paginationStatus = paginationStatus,
hasActiveFilters = hasActiveFilters,
onRetryClick = { batchFlowManager.reload() },
onRetryClick = {
batchFlowManager.reload()
reloadEarnNetworks()
},
onLoadMore = { batchFlowManager.loadMore() },
onClearFiltersClick = ::onClearFiltersClick,
)
}.onEach { bestOpportunitiesState ->
) to error
}.onEach { (bestOpportunitiesState, error) ->
error?.let(::handleBestOpportunitiesErrorAnalytics)
stateController.update(UpdateBestOpportunitiesStateTransformer(bestOpportunitiesState))
}.launchIn(modelScope)
}
@ -156,23 +156,27 @@ internal class EarnModel @Inject constructor(
private fun subscribeOnStoredFilters() {
modelScope.launch(dispatchers.default) {
getEarnFilterUseCase()
.collect { filter ->
val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType)
val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork)
stateController.update(
EarnFilterSelectedStateTransformer(
filterType = typeFilterUM,
filterNetwork = networkFilterUM,
),
)
batchFlowManager.reload()
}
combine(
getEarnFilterUseCase(),
earnNetworks,
) { filter, networks ->
val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType)
val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork)
stateController.update(
EarnFilterSelectedStateTransformer(
filterType = typeFilterUM,
filterNetwork = networkFilterUM,
earnNetworks = networks,
),
)
batchFlowManager.reload()
}.collect()
}
}
private fun fetchTopEarnTokens() {
modelScope.launch(dispatchers.default) {
stateController.update(UpdateMostlyUsedStateLoadingTransformer())
fetchTopEarnTokensUseCase()
}
}
@ -183,13 +187,21 @@ internal class EarnModel @Inject constructor(
}
}
private fun reloadEarnNetworks() {
modelScope.launch(dispatchers.default) {
if (earnNetworks.value.isLeft()) {
fetchEarnNetworks()
}
}
}
/* start of clicks area */
private fun onTypeFilterClick() {
val currentState = state.value
bottomSheetNavigation.activate(
FeedBottomSheetRoute.TypeFilter(
params = EarnTypeFilterComponent.Params(
selectedFilter = EarnFilterTypeUMConverter().convert(currentState.selectedTypeFilter),
selectedFilter = EarnFilterTypeUMConverter().convert(currentState.earnFilterUM.selectedTypeFilter),
onFilterSelected = ::onTypeFilterOptionSelected,
onDismiss = { bottomSheetNavigation.dismiss() },
),
@ -210,7 +222,7 @@ internal class EarnModel @Inject constructor(
}
private fun createNetworkFilters(): List<EarnFilterNetwork> {
val selectedFilter = state.value.selectedNetworkFilter
val selectedFilter = state.value.earnFilterUM.selectedNetworkFilter
return buildList {
add(
EarnFilterNetwork.AllNetworks(
@ -277,11 +289,14 @@ internal class EarnModel @Inject constructor(
modelScope.launch(dispatchers.default) {
setEarnFilterUseCase(
EarnFilter(
earnFilterNetwork = EarnFilterNetworkUMConverter().convert(state.value.selectedNetworkFilter),
earnFilterNetwork = EarnFilterNetworkUMConverter().convert(
value = state.value.earnFilterUM.selectedNetworkFilter,
),
earnFilterType = type,
),
)
bottomSheetNavigation.dismiss()
reloadEarnNetworks()
}
}
@ -290,7 +305,7 @@ internal class EarnModel @Inject constructor(
setEarnFilterUseCase(
EarnFilter(
earnFilterNetwork = filter,
earnFilterType = EarnFilterTypeUMConverter().convert(state.value.selectedTypeFilter),
earnFilterType = EarnFilterTypeUMConverter().convert(state.value.earnFilterUM.selectedTypeFilter),
),
)
}
@ -319,11 +334,13 @@ internal class EarnModel @Inject constructor(
is ApiResponseError.HttpException -> error.code.numericCode to error.message.orEmpty()
else -> null to ""
}
analyticsEventHandler.send(
EarnAnalyticsEvent.BestOpportunitiesLoadError(
code = code,
message = message,
),
)
if (state.value.bestOpportunities !is EarnBestOpportunitiesUM.Error) {
analyticsEventHandler.send(
EarnAnalyticsEvent.BestOpportunitiesLoadError(
code = code,
message = message,
),
)
}
}
}

View file

@ -23,7 +23,9 @@ internal fun createEarnTokensListConfig(
earnNetworks.fold(
ifLeft = { null },
ifRight = { networks ->
networks.filter(EarnNetwork::isAdded).map(EarnNetwork::networkId)
networks.filter(EarnNetwork::isAdded)
.map(EarnNetwork::networkId)
.ifEmpty { listOf(NO_ONE_NETWORK) }
},
)
}
@ -34,4 +36,9 @@ internal fun createEarnTokensListConfig(
networks = networks,
isForEarn = isForEarn,
)
}
}
/**
* This id means that backend has to return empty result
*/
private const val NO_ONE_NETWORK = "-1"

View file

@ -26,7 +26,7 @@ internal sealed class EarnAnalyticsEvent(
event = "Best Opportunities Filter Network Applied",
params = mapOf(
"Network Filter Type" to filterType.value,
"NetworkId" to networkId.orEmpty(),
"Network Id" to networkId.orEmpty(),
),
)

View file

@ -26,8 +26,10 @@ internal class EarnStateController @Inject constructor() {
return EarnUM(
mostlyUsed = EarnListUM.Loading,
bestOpportunities = EarnBestOpportunitiesUM.Loading,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
earnFilterUM = EarnFilterUM(
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},

View file

@ -1,18 +1,24 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.domain.models.earn.EarnNetworks
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnFilterSelectedStateTransformer(
private val earnNetworks: EarnNetworks,
private val filterType: EarnFilterTypeUM,
private val filterNetwork: EarnFilterNetworkUM,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(
selectedTypeFilter = filterType,
selectedNetworkFilter = filterNetwork,
earnFilterUM = prevState.earnFilterUM.copy(
selectedTypeFilter = filterType,
selectedNetworkFilter = filterNetwork,
isNetworkFilterEnabled = earnNetworks.isRight { networks -> networks.isNotEmpty() },
isTypeFilterEnabled = true,
),
)
}
}

View file

@ -1,13 +0,0 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class EarnNetworkFilterSelectedStateTransformer(
private val filter: EarnFilterNetworkUM,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(selectedNetworkFilter = filter)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.earn.state.EarnUM
internal class UpdateMostlyUsedStateLoadingTransformer : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
return prevState.copy(mostlyUsed = EarnListUM.Loading)
}
}

View file

@ -271,7 +271,7 @@ internal class FeedComponentModel @Inject constructor(
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
null
EarnListUM.Empty
},
)
}

View file

@ -70,7 +70,7 @@ internal class FeedStateController @Inject constructor(
earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) {
EarnListUM.Loading
} else {
null
EarnListUM.Empty
},
)
}

View file

@ -47,7 +47,7 @@ internal class UpdateEarnStateTransformer(
}
private fun handleEmptyState(currentState: FeedListUM): FeedListUM {
return currentState.copy(earnListUM = null)
return currentState.copy(earnListUM = EarnListUM.Empty)
}
private fun handleErrorState(currentState: FeedListUM, result: EarnError): FeedListUM {
@ -69,6 +69,8 @@ internal class UpdateEarnStateTransformer(
currentState: FeedListUM,
earnTokensWithCurrency: List<EarnTokenWithCurrency>,
): FeedListUM {
if (earnTokensWithCurrency.isEmpty()) return currentState.copy(earnListUM = EarnListUM.Empty)
val newItems = earnTokensWithCurrency
.sortedWith(
compareByDescending<EarnTokenWithCurrency> {

View file

@ -1,6 +1,6 @@
package com.tangem.features.feed.model.news.list.statemanager
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.domain.models.news.ShortArticle
import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.model.NewsListConfig

View file

@ -1,6 +1,6 @@
package com.tangem.features.feed.model.news.list.statemanager
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.features.feed.model.news.list.analytics.NewsListAnalyticsEvent

View file

@ -3,7 +3,6 @@ package com.tangem.features.feed.ui.earn
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.*
import androidx.compose.material3.Icon
@ -14,21 +13,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
@ -39,6 +38,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.earn.components.EarnItemPlaceholder
import com.tangem.features.feed.ui.earn.components.EarnListItem
import com.tangem.features.feed.ui.earn.components.MostlyUsedCard
import com.tangem.features.feed.ui.earn.components.MostlyUsedPlaceholder
import com.tangem.features.feed.ui.earn.state.*
import kotlinx.collections.immutable.persistentListOf
@ -91,12 +91,7 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
SpacerH(12.dp)
BestOpportunitiesFilters(
state = state.bestOpportunities,
selectedNetworkFilterText = when (state.selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks)
is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks)
is EarnFilterNetworkUM.Network -> TextReference.Str(state.selectedNetworkFilter.text)
},
selectedTypeFilterText = state.selectedTypeFilterText,
earnFilterUM = state.earnFilterUM,
onNetworkFilterClick = state.onNetworkFilterClick,
onTypeFilterClick = state.onTypeFilterClick,
)
@ -113,8 +108,8 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
AnimatedContent(
targetState = state,
contentKey = { it::class.java },
) { st ->
when (st) {
) { animatedState ->
when (animatedState) {
is EarnListUM.Loading -> {
MostlyUsedPlaceholder()
}
@ -127,7 +122,7 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(
items = st.items,
items = animatedState.items,
key = { _, item -> "${item.tokenName}-${item.network}" },
) { index, item ->
val cardModifier = Modifier.conditional(
@ -151,84 +146,33 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 12.dp,
),
.padding(horizontal = 16.dp, vertical = 12.dp)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(vertical = 32.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = st.onRetryClicked)
UnableToLoadData(onRetryClick = animatedState.onRetryClicked)
}
}
EarnListUM.Empty -> Unit // no need to handle
}
}
}
@Composable
private fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(148.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(onClick = onClick)
.padding(12.dp),
) {
CurrencyIcon(
modifier = Modifier.size(32.dp),
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = 12.dp,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors.text.accent,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun BestOpportunitiesFilters(
state: EarnBestOpportunitiesUM,
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
earnFilterUM: EarnFilterUM,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
) {
when (state) {
is EarnBestOpportunitiesUM.Loading -> FilterButtonsShimmer()
else -> FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = state is EarnBestOpportunitiesUM.Content || state is EarnBestOpportunitiesUM.EmptyFiltered,
earnFilterUM = earnFilterUM,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
@ -307,9 +251,7 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnBestOpportunitiesUM)
@Composable
private fun FilterButtons(
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
isEnabled: Boolean,
earnFilterUM: EarnFilterUM,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
modifier: Modifier = Modifier,
@ -319,10 +261,14 @@ private fun FilterButtons(
) {
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedNetworkFilterText,
text = when (earnFilterUM.selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks)
is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks)
is EarnFilterNetworkUM.Network -> TextReference.Str(earnFilterUM.selectedNetworkFilter.text)
},
onClick = onNetworkFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
isEnabled = earnFilterUM.isNetworkFilterEnabled,
),
)
@ -330,10 +276,10 @@ private fun FilterButtons(
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedTypeFilterText,
text = earnFilterUM.selectedTypeFilter.text,
onClick = onTypeFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
isEnabled = earnFilterUM.isTypeFilterEnabled,
),
)
}
@ -496,7 +442,7 @@ private fun EarnContentLoadingPreview() {
) {
EarnContent(
state = previewEarnUM(
mostlyUsed = EarnListUM.Loading,
mostlyUsed = EarnListUM.Error(onRetryClicked = {}),
bestOpportunities = EarnBestOpportunitiesUM.Loading,
),
)
@ -588,8 +534,12 @@ private fun previewEarnUM(
): EarnUM = EarnUM(
mostlyUsed = mostlyUsed,
bestOpportunities = bestOpportunities,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
earnFilterUM = EarnFilterUM(
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
isTypeFilterEnabled = true,
isNetworkFilterEnabled = true,
),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},

View file

@ -0,0 +1,196 @@
package com.tangem.features.feed.ui.earn.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.ds.opportunities.OpportunitiesBG
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.*
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
@Composable
internal fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
if (LocalRedesignEnabled.current) {
MostlyUsedCardV2(
modifier = modifier,
item = item,
onClick = onClick,
)
} else {
MostlyUsedCardV1(
modifier = modifier,
item = item,
onClick = onClick,
)
}
}
@Composable
private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
OpportunitiesBG(
modifier = modifier
.width(148.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable(onClick = onClick),
icon = TangemIconUM.Currency(item.currencyIconState),
) {
Column(modifier = Modifier.padding(12.dp)) {
CurrencyIcon(
modifier = Modifier.size(32.dp),
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = 12.dp,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(22.dp)
Row(
verticalAlignment = Alignment.Bottom,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors2.text.status.positive,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
)
}
}
}
@Composable
private fun MostlyUsedCardV1(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(148.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(onClick = onClick)
.padding(12.dp),
) {
CurrencyIcon(
modifier = Modifier.size(32.dp),
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = 12.dp,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors.text.accent,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun EarnListItemPreviewV1() {
TangemThemePreview {
MostlyUsedCardV1(
EarnListItemUM(
network = stringReference("Ethereum"),
symbol = stringReference("USDT"),
tokenName = stringReference("Tether"),
currencyIconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
onItemClick = {},
),
onClick = {},
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun EarnListItemPreviewV2() {
TangemThemePreviewRedesign {
MostlyUsedCardV2(
EarnListItemUM(
network = stringReference("Ethereum"),
symbol = stringReference("USDT"),
tokenName = stringReference("Tether"),
currencyIconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
onItemClick = {},
),
onClick = {},
)
}
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
@Immutable
internal data class EarnFilterUM(
val selectedTypeFilter: EarnFilterTypeUM,
val selectedNetworkFilter: EarnFilterNetworkUM,
val isTypeFilterEnabled: Boolean = true,
val isNetworkFilterEnabled: Boolean = true,
)

View file

@ -10,6 +10,7 @@ internal sealed interface EarnListUM {
data object Loading : EarnListUM
data class Content(val items: ImmutableList<EarnListItemUM>) : EarnListUM
data class Error(val onRetryClicked: () -> Unit) : EarnListUM
data object Empty : EarnListUM
}
@Immutable

View file

@ -1,20 +1,14 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal data class EarnUM(
val mostlyUsed: EarnListUM,
val bestOpportunities: EarnBestOpportunitiesUM,
val selectedTypeFilter: EarnFilterTypeUM,
val selectedNetworkFilter: EarnFilterNetworkUM,
val earnFilterUM: EarnFilterUM,
val onBackClick: () -> Unit,
val onNetworkFilterClick: () -> Unit,
val onTypeFilterClick: () -> Unit,
val onSliderScroll: () -> Unit,
) {
val selectedTypeFilterText: TextReference
get() = selectedTypeFilter.text
}
)

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -8,12 +9,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.extensions.TextReference
@Composable
internal fun Header(onSeeAllClick: () -> Unit, isLoading: Boolean = false, title: @Composable () -> Unit) {
internal fun Header(
onSeeAllClick: () -> Unit,
isLoading: Boolean,
shouldShowSeeAll: Boolean,
title: @Composable () -> Unit,
) {
AnimatedContent(isLoading) { animatedState ->
Row(
modifier = Modifier
@ -25,13 +32,18 @@ internal fun Header(onSeeAllClick: () -> Unit, isLoading: Boolean = false, title
if (animatedState) {
RectangleShimmer(modifier = Modifier.size(width = 104.dp, height = 18.dp))
} else {
title()
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
Box(modifier = Modifier.weight(1f)) {
title()
}
SpacerW(8.dp)
AnimatedVisibility(shouldShowSeeAll) {
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.common_see_all),
onClick = onSeeAllClick,
),
)
}
}
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
@ -23,8 +24,8 @@ import com.tangem.features.feed.ui.earn.state.EarnListUM
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM?, modifier: Modifier = Modifier) {
if (earnListUM == null) return
internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM, modifier: Modifier = Modifier) {
if (earnListUM is EarnListUM.Empty) return
Column(modifier = modifier) {
Header(
@ -33,10 +34,13 @@ internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM?, modif
text = stringResourceSafe(R.string.markets_earn_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = onSeeAllClick,
isLoading = earnListUM is EarnListUM.Loading,
shouldShowSeeAll = earnListUM is EarnListUM.Content,
)
SpacerH(12.dp)
@ -53,6 +57,7 @@ internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM?, modif
is EarnListUM.Content -> EarnContentBlock(items = earnListUM.items)
is EarnListUM.Error -> EarnErrorBlock(onRetryClick = earnListUM.onRetryClicked)
EarnListUM.Loading -> EarnListPlaceholder(placeholderCount = PLACEHOLDER_ITEM_COUNT)
EarnListUM.Empty -> Unit
}
}
}

View file

@ -9,8 +9,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.news.DefaultLoadingArticle
import com.tangem.common.ui.news.TrendingLoadingArticle
import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle
import com.tangem.features.feed.ui.feed.components.articles.TrendingLoadingArticle
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.block.BlockCard

View file

@ -4,12 +4,7 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -19,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.MarketsListItem
@ -55,9 +51,13 @@ internal fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedLis
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
shouldShowSeeAll = currentChart is MarketChartUM.Content,
isLoading = currentChart is MarketChartUM.Loading,
)
SpacerH(12.dp)
@ -88,9 +88,13 @@ internal fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCall
text = stringResourceSafe(R.string.markets_pulse_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = { onSeeAllClick() },
shouldShowSeeAll = true,
isLoading = marketChartConfig.marketCharts[marketChartConfig.currentSortByType] is MarketChartUM.Loading,
)
LazyRow(

View file

@ -2,28 +2,26 @@ package com.tangem.features.feed.ui.feed.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.common.ui.news.ShowMoreArticlesCard
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
@ -31,16 +29,18 @@ import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.state.FeedListCallbacks
import com.tangem.features.feed.ui.feed.state.NewsUM
import com.tangem.features.feed.ui.feed.state.NewsUMState
import com.tangem.features.feed.ui.feed.state.*
private const val FOURTH_ITEM_INDEX = 3
internal const val FOURTH_ITEM_INDEX = 3
private const val GRADIENT_START = 0f
private const val GRADIENT_END = 0.5f
private val LinearGradientFirstPart = Color(0xFF635EEC)
private val LinearGradientSecondPart = Color(0xFFE05AED)
private const val LINEAR_GRADIENT_FIRST_PART_V2 = 0xFFA3A0FF
private const val LINEAR_GRADIENT_SECOND_PART_V2 = 0xFFF79DFF
private const val LINEAR_GRADIENT_FIRST_PART_V1 = 0xFF635EEC
private const val LINEAR_GRADIENT_SECOND_PART_V1 = 0xFFE05AED
@Composable
internal fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
@ -64,13 +64,23 @@ internal fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trend
@Suppress("LongMethod")
@Composable
private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) {
val listState = rememberLazyListState()
val articlesReadStatus = remember(news.content) {
news.content.map { it.isViewed }
val isRedesignEnabled = LocalRedesignEnabled.current
val gradientStart = remember(isRedesignEnabled) {
if (isRedesignEnabled) {
Color(LINEAR_GRADIENT_FIRST_PART_V2)
} else {
Color(LINEAR_GRADIENT_FIRST_PART_V1)
}
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
val gradientEnd = remember(isRedesignEnabled) {
if (isRedesignEnabled) {
Color(LINEAR_GRADIENT_SECOND_PART_V2)
} else {
Color(LINEAR_GRADIENT_SECOND_PART_V1)
}
}
Column {
Header(
title = {
@ -95,8 +105,8 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
withStyle(
SpanStyle().copy(
brush = Brush.linearGradient(
GRADIENT_START to LinearGradientFirstPart,
GRADIENT_END to LinearGradientSecondPart,
GRADIENT_START to gradientStart,
GRADIENT_END to gradientEnd,
),
),
) {
@ -104,10 +114,14 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
}
},
style = TangemTheme.typography.subtitle1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
onSeeAllClick = { feedListCallbacks.onOpenAllNews(false) },
isLoading = news.newsUMState == NewsUMState.LOADING,
shouldShowSeeAll = news.newsUMState == NewsUMState.CONTENT,
)
SpacerH(12.dp)
@ -125,48 +139,19 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
}
}
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = listState,
) {
itemsIndexed(
items = news.content,
key = { _, article -> article.id },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { feedListCallbacks.onArticleClick(article.id) },
modifier = articleModifier
.heightIn(min = 164.dp)
.width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
NewsSlider(
NewsSliderConfig(
callbacks = NewsSliderCallbacks(
onOpenAllNews = { feedListCallbacks.onOpenAllNews(true) },
onSliderScroll = feedListCallbacks.onSliderScroll,
onSliderEndReached = feedListCallbacks.onSliderEndReached,
onArticleClick = feedListCallbacks.onArticleClick,
),
content = news.content,
shouldShowSeeAllNewsItem = true,
),
)
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.width(216.dp)
.heightIn(min = 164.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = feedListCallbacks.onSliderEndReached,
),
onClick = { feedListCallbacks.onOpenAllNews(true) },
)
}
}
SpacerH(32.dp)
}
}
@ -181,10 +166,14 @@ private fun NewsErrorBlock(onRetryClick: () -> Unit) {
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
onSeeAllClick = {},
shouldShowSeeAll = false,
isLoading = false,
)
SpacerH(12.dp)
BlockCard(

View file

@ -0,0 +1,96 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.HorizontalFadeWithBlur
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ShowMoreArticlesCard
import com.tangem.features.feed.ui.feed.state.NewsSliderConfig
@Suppress("LongMethod")
@Composable
internal fun NewsSlider(newsSliderConfig: NewsSliderConfig) {
val background = LocalMainBottomSheetColor.current.value
val isRedesignEnabled = LocalRedesignEnabled.current
Box(
modifier = Modifier.fillMaxWidth(),
) {
LazyRow(
modifier = Modifier
.conditionalCompose(
condition = isRedesignEnabled,
modifier = {
hazeSourceTangem(zIndex = -1f)
},
)
.background(color = background),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
) {
itemsIndexed(
items = newsSliderConfig.content,
key = { index, _ -> index },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { newsSliderConfig.callbacks.onArticleClick(article.id) },
modifier = articleModifier
.width(228.dp)
.heightIn(min = 172.dp)
.fillMaxHeight(),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
if (newsSliderConfig.shouldShowSeeAllNewsItem) {
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.width(228.dp)
.heightIn(min = 172.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderEndReached,
),
onClick = newsSliderConfig.callbacks.onOpenAllNews,
)
}
}
}
if (isRedesignEnabled) {
HorizontalFadeWithBlur(
modifier = Modifier
.align(Alignment.TopEnd)
.heightIn(min = 172.dp)
.fillMaxHeight()
.width(100.dp),
backgroundColor = background,
)
}
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.feed.ui.feed.components.articles
import androidx.compose.material3.CardColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.res.LocalRedesignEnabled
@Composable
fun ArticleCard(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
modifier: Modifier = Modifier,
colors: CardColors = TangemBlockCardColors,
) {
if (LocalRedesignEnabled.current) {
ArticleCardV2(
articleConfigUM = articleConfigUM,
onArticleClick = onArticleClick,
modifier = modifier,
)
} else {
ArticleCardV1(
articleConfigUM = articleConfigUM,
onArticleClick = onArticleClick,
modifier = modifier,
colors = colors,
)
}
}

View file

@ -0,0 +1,273 @@
package com.tangem.features.feed.ui.feed.components.articles
import android.content.res.Configuration
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardColors
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerHMax
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@Composable
internal fun ArticleCardV1(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
modifier: Modifier = Modifier,
colors: CardColors = TangemBlockCardColors,
) {
if (articleConfigUM.isTrending) {
TrendingArticle(
articleConfigUM = articleConfigUM,
modifier = modifier,
onArticleClick = onArticleClick,
colors = colors,
)
} else {
DefaultArticle(
articleConfigUM = articleConfigUM,
modifier = modifier,
onArticleClick = onArticleClick,
colors = colors,
)
}
}
@Composable
private fun TrendingArticle(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
colors: CardColors,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(colors.containerColor)
.clickable { onArticleClick() },
) {
Image(
modifier = Modifier.matchParentSize(),
painter = painterResource(R.drawable.bg_trending_article),
contentDescription = null,
contentScale = ContentScale.FillWidth,
)
Column(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = RoundedCornerShape(8.dp),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
text = stringResourceSafe(R.string.feed_trending_now),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.icon.accent,
)
SpacerH(12.dp)
Text(
text = articleConfigUM.title,
color = if (articleConfigUM.isViewed) {
TangemTheme.colors.text.tertiary
} else {
TangemTheme.colors.text.primary1
},
style = TangemTheme.typography.h3,
textAlign = TextAlign.Center,
)
SpacerH(8.dp)
ArticleInfo(
score = articleConfigUM.score,
createdAt = articleConfigUM.createdAt.resolveReference(),
)
SpacerH(32.dp)
Tags(
modifier = Modifier.padding(horizontal = 46.dp),
tags = articleConfigUM.tags.toImmutableList(),
)
}
}
}
@Composable
internal fun ShowMoreArticlesCardV1(modifier: Modifier = Modifier, onClick: () -> Unit) {
BlockCard(
modifier = modifier,
onClick = onClick,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(vertical = 31.dp, horizontal = 12.dp),
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_show_more_news_48),
contentDescription = stringResourceSafe(R.string.common_show_more),
)
SpacerH(16.dp)
Text(
text = stringResourceSafe(R.string.news_all_news),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
text = stringResourceSafe(R.string.news_stay_in_the_loop),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable
private fun DefaultArticle(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
colors: CardColors,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(colors.containerColor)
.clickable { onArticleClick() }
.padding(12.dp),
) {
ArticleInfo(
score = articleConfigUM.score,
createdAt = articleConfigUM.createdAt.resolveReference(),
)
SpacerH(8.dp)
Text(
text = articleConfigUM.title,
color = if (articleConfigUM.isViewed) {
TangemTheme.colors.text.tertiary
} else {
TangemTheme.colors.text.primary1
},
style = TangemTheme.typography.subtitle1,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
SpacerH(16.dp)
SpacerHMax()
Tags(tags = articleConfigUM.tags.toImmutableList())
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TagsPreview() {
TangemThemePreview {
Tags(
tags = persistentListOf(
LabelUM(TextReference.Str("Hype")),
LabelUM(TextReference.Str("BTC")),
LabelUM(TextReference.Str("Supply")),
LabelUM(TextReference.Str("Demand")),
LabelUM(TextReference.Str("Best rate")),
LabelUM(TextReference.Str("Breaking news")),
),
)
}
}
@Preview(showBackground = true, widthDp = 360, backgroundColor = 0xFF000000)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ArticleCardsPreview() {
val tags = listOf(
LabelUM(TextReference.Str("Hype")),
LabelUM(TextReference.Str("BTC")),
LabelUM(TextReference.Str("Supply")),
LabelUM(TextReference.Str("Demand")),
LabelUM(TextReference.Str("Breaking news")),
).toImmutableSet()
val config = ArticleConfigUM(
id = 1,
title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)",
score = 9.5f,
createdAt = TextReference.Str("1h ago"),
isTrending = true,
tags = tags,
isViewed = false,
)
TangemThemePreview {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
ArticleCard(
articleConfigUM = config,
onArticleClick = {},
)
SpacerH(20.dp)
ArticleCard(
articleConfigUM = config.copy(isViewed = true),
onArticleClick = {},
)
SpacerH(20.dp)
ArticleCard(
articleConfigUM = config.copy(isTrending = false),
onArticleClick = {},
)
SpacerH(20.dp)
ArticleCard(
articleConfigUM = config.copy(isTrending = false, isViewed = true),
onArticleClick = {},
)
}
}
}

View file

@ -0,0 +1,405 @@
package com.tangem.features.feed.ui.feed.components.articles
import android.content.res.Configuration
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_NO
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
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.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@Composable
internal fun ArticleCardV2(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (articleConfigUM.isTrending) {
TrendingArticle(
modifier = modifier,
articleConfigUM = articleConfigUM,
onArticleClick = onArticleClick,
)
} else {
DefaultArticle(
modifier = modifier,
articleConfigUM = articleConfigUM,
onArticleClick = onArticleClick,
)
}
}
@Composable
private fun TrendingArticle(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TrendingArticleBackground(
modifier = modifier,
onClick = onArticleClick,
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.Start,
) {
DayAndRatingInfo(rating = stringReference("${articleConfigUM.score}"))
SpacerH(8.dp)
Text(
text = articleConfigUM.title,
color = if (articleConfigUM.isViewed) {
TangemTheme.colors2.text.neutral.tertiary
} else {
TangemTheme.colors2.text.neutral.primary
},
style = TangemTheme.typography2.headingSemibold20,
textAlign = TextAlign.Start,
)
SpacerH(18.dp)
Text(
text = articleConfigUM.createdAt.resolveReference(),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.secondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerH(18.dp)
Tags(tags = articleConfigUM.tags.toImmutableList())
}
}
}
@Composable
internal fun ShowMoreArticlesCardV2(modifier: Modifier = Modifier, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(20.dp))
.background(color = TangemTheme.colors2.surface.level3)
.clickable(onClick = onClick)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.primary,
shape = RoundedCornerShape(20.dp),
)
.padding(vertical = 41.dp, horizontal = 16.dp),
) {
Image(
modifier = Modifier.size(40.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_show_more_news_48),
contentDescription = stringResourceSafe(R.string.common_show_more),
)
SpacerH(10.dp)
Text(
text = stringResourceSafe(R.string.news_all_news),
style = TangemTheme.typography2.bodyRegular16,
color = TangemTheme.colors2.text.neutral.primary,
)
SpacerH(4.dp)
Text(
text = stringResourceSafe(R.string.news_stay_in_the_loop),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
}
@Composable
private fun DefaultArticle(
articleConfigUM: ArticleConfigUM,
onArticleClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(20.dp))
.background(color = TangemTheme.colors2.surface.level3)
.clickable(onClick = onArticleClick)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.primary,
shape = RoundedCornerShape(20.dp),
)
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
RatingInfo(
rating = stringReference("${articleConfigUM.score}"),
isTrending = false,
)
}
SpacerH(8.dp)
Text(
modifier = Modifier.weight(1f),
text = articleConfigUM.title,
color = if (articleConfigUM.isViewed) {
TangemTheme.colors2.text.neutral.tertiary
} else {
TangemTheme.colors2.text.neutral.primary
},
style = TangemTheme.typography2.bodyRegular16,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
SpacerH(8.dp)
Text(
text = articleConfigUM.createdAt.resolveReference(),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.secondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerH(8.dp)
Tags(tags = articleConfigUM.tags.toImmutableList())
}
}
@Suppress("MagicNumber")
@Composable
private fun TrendingArticleBackground(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
val isDarkTheme = LocalIsInDarkTheme.current
val bgColor = remember(isDarkTheme) {
if (isDarkTheme) {
Color(TRENDING_NIGHT_BG)
} else {
Color(TRENDING_LIGHT_BG)
}
}
Box(
modifier = modifier
.clip(RoundedCornerShape(20.dp))
.drawBehind {
drawRect(bgColor)
val w = size.width
val h = size.height
val radiusScale = (w + h) / 2f
drawRect(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFF7C16F1).copy(alpha = .8f),
Color.Transparent,
),
center = Offset(w / 2f, 2.4f * h),
radius = radiusScale * 1.57f,
),
)
drawRect(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFF3360FF).copy(alpha = .7f),
Color.Transparent,
),
center = Offset(w / 2f, 2.95f * h),
radius = radiusScale * 1.9f,
),
)
drawRect(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFFFF9408).copy(alpha = .45f),
Color.Transparent,
),
center = Offset(-0.38f * w, 2.1f * h),
radius = radiusScale * 1.41f,
),
)
drawRect(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFFFC2424).copy(alpha = .5f),
Color.Transparent,
),
center = Offset(1.188f * w, 2.37f * h),
radius = radiusScale * 1.41f,
),
)
}
.border(width = 1.dp, color = bgColor.copy(.1f))
.clickable(onClick = onClick),
content = content,
)
}
@Composable
private fun DayAndRatingInfo(rating: TextReference, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
RatingInfo(rating = rating, isTrending = true)
SpacerW(8.dp)
Text(
text = stringResourceSafe(R.string.feed_trending_now),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.primary,
)
}
}
@Composable
private fun RatingInfo(rating: TextReference, isTrending: Boolean) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_wrapped_circle_star_16),
tint = if (isTrending) {
TangemTheme.colors2.fill.status.attention
} else {
TangemTheme.colors2.markers.iconGray
},
contentDescription = null,
)
SpacerW(2.dp)
Text(
text = rating.resolveReference(),
color = if (isTrending) {
TangemTheme.colors2.text.status.attention
} else {
TangemTheme.colors2.text.neutral.secondary
},
style = TangemTheme.typography2.captionSemibold12,
)
}
private const val TRENDING_NIGHT_BG = 0xFF1F1F1F
private const val TRENDING_LIGHT_BG = 0xFFFFFFFF
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TagsPreview() {
TangemThemePreviewRedesign {
Tags(
tags = persistentListOf(
LabelUM(TextReference.Str("Hype")),
LabelUM(TextReference.Str("BTC")),
LabelUM(TextReference.Str("Supply")),
LabelUM(TextReference.Str("Demand")),
LabelUM(TextReference.Str("Best rate")),
LabelUM(TextReference.Str("Breaking news")),
),
)
}
}
@Preview(widthDp = 360, uiMode = UI_MODE_NIGHT_YES)
@Preview(widthDp = 360, uiMode = UI_MODE_NIGHT_NO)
@Composable
private fun ArticleCardsPreview() {
val tags = listOf(
LabelUM(TextReference.Str("Hype")),
LabelUM(TextReference.Str("BTC")),
LabelUM(TextReference.Str("Supply")),
LabelUM(TextReference.Str("Demand")),
LabelUM(TextReference.Str("Breaking news")),
).toImmutableSet()
val config = ArticleConfigUM(
id = 1,
title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)",
score = 9.5f,
createdAt = TextReference.Str("1h ago"),
isTrending = true,
tags = tags,
isViewed = false,
)
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
) {
ArticleCardV2(
articleConfigUM = config,
onArticleClick = {},
)
SpacerH(20.dp)
ArticleCardV2(
articleConfigUM = config.copy(isViewed = true),
onArticleClick = {},
)
SpacerH(20.dp)
ArticleCardV2(
articleConfigUM = config.copy(isTrending = false),
onArticleClick = {},
)
SpacerH(20.dp)
ArticleCardV2(
articleConfigUM = config.copy(isTrending = false, isViewed = true),
onArticleClick = {},
)
SpacerH(20.dp)
ShowMoreArticlesCardV2(onClick = {})
}
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.feed.ui.feed.components.articles
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableSet
@Immutable
data class ArticleConfigUM(
val id: Int,
val title: String,
val score: Float,
val createdAt: TextReference,
val isTrending: Boolean,
val tags: ImmutableSet<LabelUM>,
val isViewed: Boolean,
)

View file

@ -0,0 +1,55 @@
package com.tangem.features.feed.ui.feed.components.articles
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ArticleHeader(
title: String,
createdAt: String,
score: Float,
tags: ImmutableList<LabelUM>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
ArticleInfo(
score = score,
createdAt = createdAt,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = title,
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
)
if (tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(20.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
tags.forEach { tag ->
Label(
state = tag,
)
}
}
}
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.features.feed.ui.feed.components.articles
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun ArticleInfo(score: Float, createdAt: String, modifier: Modifier = Modifier) {
val dotColor = TangemTheme.colors.text.secondary
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_start_circle_12),
contentDescription = null,
)
Text(
text = score.toString(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.secondary,
)
Spacer(
modifier = Modifier
.size(4.dp)
.drawWithCache {
val radius = size.minDimension / 2f
onDrawBehind {
drawCircle(
color = dotColor,
radius = radius,
)
}
},
)
Text(
text = createdAt,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors.text.secondary,
)
}
}

View file

@ -0,0 +1,220 @@
package com.tangem.features.feed.ui.feed.components.articles
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
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
@Composable
fun TrendingLoadingArticle(modifier: Modifier = Modifier) {
if (LocalRedesignEnabled.current) {
TrendingLoadingArticleV2(modifier)
} else {
TrendingLoadingArticleV1(modifier)
}
}
@Composable
private fun TrendingLoadingArticleV1(modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RectangleShimmer(modifier = Modifier.size(width = 96.dp, height = 24.dp), radius = 8.dp)
SpacerH(12.dp)
RectangleShimmer(modifier = Modifier.size(width = 285.dp, height = 18.dp), radius = 4.dp)
SpacerH(6.dp)
RectangleShimmer(modifier = Modifier.size(width = 190.dp, height = 18.dp), radius = 4.dp)
SpacerH(14.dp)
RectangleShimmer(modifier = Modifier.size(width = 110.dp, height = 18.dp), radius = 4.dp)
SpacerH(32.dp)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 24.dp), radius = 8.dp)
RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 24.dp), radius = 8.dp)
RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 24.dp), radius = 8.dp)
}
}
}
}
@Composable
private fun TrendingLoadingArticleV2(modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors2.surface.level3),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
RectangleShimmer(modifier = Modifier.size(width = 46.dp, height = 16.dp), radius = TangemTheme.dimens2.x25)
SpacerH(8.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(24.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(48.dp)
RectangleShimmer(modifier = Modifier.size(width = 46.dp, height = 16.dp), radius = TangemTheme.dimens2.x25)
SpacerH(12.dp)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RectangleShimmer(
modifier = Modifier.size(width = 58.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier.size(width = 58.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier.size(width = 58.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
}
}
}
}
@Composable
fun DefaultLoadingArticle(modifier: Modifier = Modifier) {
if (LocalRedesignEnabled.current) {
DefaultLoadingArticleV2(modifier)
} else {
DefaultLoadingArticleV1(modifier)
}
}
@Composable
private fun DefaultLoadingArticleV1(modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
RectangleShimmer(modifier = Modifier.size(width = 46.dp, height = 16.dp), radius = TangemTheme.dimens2.x25)
SpacerH(8.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(20.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(28.dp)
RectangleShimmer(modifier = Modifier.size(width = 46.dp, height = 16.dp), radius = TangemTheme.dimens2.x25)
SpacerH(8.dp)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RectangleShimmer(
modifier = Modifier.size(width = 72.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier.size(width = 62.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier.size(width = 32.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
}
}
}
}
@Composable
private fun DefaultLoadingArticleV2(modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors2.surface.level3),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
RectangleShimmer(modifier = Modifier.size(width = 46.dp, height = 16.dp), radius = TangemTheme.dimens2.x25)
SpacerH(8.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(20.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(44.dp)
RectangleShimmer(modifier = Modifier.size(width = 46.dp, height = 16.dp), radius = TangemTheme.dimens2.x25)
SpacerH(12.dp)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RectangleShimmer(
modifier = Modifier.size(width = 72.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier.size(width = 62.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier.size(width = 32.dp, height = 24.dp),
radius = TangemTheme.dimens2.x25,
)
}
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TrendingLoadingArticlePreviewV1() {
TangemThemePreview {
TrendingLoadingArticle()
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TrendingLoadingArticlePreviewV2() {
TangemThemePreviewRedesign {
TrendingLoadingArticle()
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun DefaultLoadingArticlePreviewV1() {
TangemThemePreview {
DefaultLoadingArticle()
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun DefaultLoadingArticlePreviewV2() {
TangemThemePreviewRedesign {
DefaultLoadingArticle()
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.feed.ui.feed.components.articles
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.LocalRedesignEnabled
@Composable
fun ShowMoreArticlesCard(modifier: Modifier = Modifier, onClick: () -> Unit) {
val isRedesignEnabled: Boolean = LocalRedesignEnabled.current
if (isRedesignEnabled) {
ShowMoreArticlesCardV2(modifier = modifier, onClick = onClick)
} else {
ShowMoreArticlesCardV1(modifier = modifier, onClick = onClick)
}
}

View file

@ -0,0 +1,160 @@
package com.tangem.features.feed.ui.feed.components.articles
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.SubcomposeMeasureScope
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.label.Label
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.ds.badge.*
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
private val TAG_SPACING = 4.dp
/**
* A layout that displays a list of tags, automatically calculating how many can fit
* in a single line. If not all tags fit, it shows an overflow indicator (e.g., "+N").
*
* @param tags The immutable list of [LabelUM] to display.
* @param modifier The modifier to be applied to the layout.
*/
@Composable
internal fun Tags(tags: ImmutableList<LabelUM>, modifier: Modifier = Modifier) {
if (tags.isEmpty()) return
SubcomposeLayout(modifier = modifier) { constraints ->
val spacingPx = TAG_SPACING.roundToPx()
val tagPlaceables = subcompose(ContentSlot.Tags) {
tags.forEach { tag ->
if (LocalRedesignEnabled.current) {
TangemBadge(
text = tag.text,
tangemIconUM = when (val content = tag.leadingContent) {
LabelLeadingContentUM.None -> null
is LabelLeadingContentUM.Token -> TangemIconUM.Url(content.iconUrl)
},
shape = TangemBadgeShape.Rounded,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Gray,
iconPosition = when (tag.leadingContent) {
LabelLeadingContentUM.None -> TangemBadgeIconPosition.None
is LabelLeadingContentUM.Token -> TangemBadgeIconPosition.Start
},
)
} else {
Label(state = tag)
}
}
}.map { it.measure(constraints) }
val layoutInfo = calculateLayoutInfo(
maxWidth = constraints.maxWidth,
spacingPx = spacingPx,
tagPlaceables = tagPlaceables,
)
val overflowPlaceable = layoutInfo.overflowCount.takeIf { it > 0 }?.let {
subcompose(ContentSlot.Overflow) {
OverflowLabel(count = it)
}.first().measure(constraints)
}
val visiblePlaceables = tagPlaceables.take(layoutInfo.visibleCount)
val allVisibleItems = visiblePlaceables + listOfNotNull(overflowPlaceable)
val width = calculateRowWidth(
placeables = allVisibleItems,
spacingPx = spacingPx,
).coerceIn(constraints.minWidth, constraints.maxWidth)
val height = allVisibleItems.maxOfOrNull { it.height } ?: 0
layout(width = width, height = height) {
var xPosition = 0
allVisibleItems.forEachIndexed { index, placeable ->
if (index > 0) {
xPosition += spacingPx
}
val yPosition = height - placeable.height
placeable.placeRelative(xPosition, yPosition)
xPosition += placeable.width
}
}
}
}
/**
* Calculates how many tags can be displayed and how many are overflowing.
*/
private fun SubcomposeMeasureScope.calculateLayoutInfo(
maxWidth: Int,
spacingPx: Int,
tagPlaceables: List<Placeable>,
): TagsLayoutInfo {
val totalWidthWithoutOverflow = calculateRowWidth(tagPlaceables, spacingPx)
if (totalWidthWithoutOverflow <= maxWidth) {
return TagsLayoutInfo(visibleCount = tagPlaceables.size, overflowCount = 0)
}
for (count in tagPlaceables.indices.reversed()) {
val visibleTags = tagPlaceables.take(count)
val overflowCount = tagPlaceables.size - count
val tempOverflowPlaceable = subcompose("overflow_temp_$overflowCount") {
OverflowLabel(count = overflowCount)
}.first().measure(Constraints())
val widthWithOverflow = calculateRowWidth(
placeables = visibleTags + tempOverflowPlaceable,
spacingPx = spacingPx,
)
if (widthWithOverflow <= maxWidth) {
return TagsLayoutInfo(visibleCount = count, overflowCount = overflowCount)
}
}
return TagsLayoutInfo(visibleCount = 0, overflowCount = tagPlaceables.size)
}
@Composable
private fun OverflowLabel(count: Int) {
if (LocalRedesignEnabled.current) {
TangemBadge(
text = TextReference.Str("${StringsSigns.PLUS}$count"),
shape = TangemBadgeShape.Rounded,
size = TangemBadgeSize.X6,
type = TangemBadgeType.Tinted,
color = TangemBadgeColor.Gray,
)
} else {
Label(
state = LabelUM(
text = TextReference.Str("${StringsSigns.PLUS}$count"),
maxLines = 1,
),
)
}
}
private fun calculateRowWidth(placeables: List<Placeable>, spacingPx: Int): Int {
if (placeables.isEmpty()) return 0
val tagsWidth = placeables.sumOf { it.width }
val spacingWidth = spacingPx * (placeables.size - 1).coerceAtLeast(0)
return tagsWidth + spacingWidth
}
private data class TagsLayoutInfo(val visibleCount: Int, val overflowCount: Int)
private enum class ContentSlot { Tags, Overflow }

View file

@ -2,7 +2,7 @@ package com.tangem.features.feed.ui.feed.preview
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM

View file

@ -2,7 +2,7 @@ package com.tangem.features.feed.ui.feed.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
@ -18,7 +18,7 @@ internal data class FeedListUM(
val trendingArticle: ArticleConfigUM?,
val marketChartConfig: MarketChartConfig,
val globalState: GlobalFeedState = GlobalFeedState.Content,
val earnListUM: EarnListUM?,
val earnListUM: EarnListUM,
)
internal data class FeedListCallbacks(

View file

@ -0,0 +1,20 @@
package com.tangem.features.feed.ui.feed.state
import androidx.compose.runtime.Immutable
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class NewsSliderConfig(
val shouldShowSeeAllNewsItem: Boolean,
val content: ImmutableList<ArticleConfigUM>,
val callbacks: NewsSliderCallbacks,
)
@Immutable
internal data class NewsSliderCallbacks(
val onOpenAllNews: () -> Unit,
val onSliderScroll: () -> Unit,
val onSliderEndReached: () -> Unit,
val onArticleClick: (id: Int) -> Unit,
)

View file

@ -42,7 +42,6 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.components.*
import com.tangem.core.ui.R as CoreR
import com.tangem.features.feed.ui.market.detailed.preview.MarketsTokenDetailsPreview
import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent
import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent
@ -50,13 +49,13 @@ import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreBottomSheetContent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.distinctUntilChanged
import com.tangem.core.ui.R as CoreR
@Suppress("LongParameterList")
@Composable
internal fun MarketsTokenDetailsContent(
state: MarketsTokenDetailsUM,
backgroundColor: Color,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
@ -65,7 +64,6 @@ internal fun MarketsTokenDetailsContent(
backgroundColor = backgroundColor,
state = state,
portfolioBlock = portfolioBlock,
isAccountEnabled = isAccountEnabled,
)
when (state.bottomSheetConfig.content) {
@ -80,7 +78,6 @@ internal fun MarketsTokenDetailsContent(
private fun Content(
state: MarketsTokenDetailsUM,
backgroundColor: Color,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
@ -134,7 +131,6 @@ private fun Content(
tokenMarketDetailsBody(
state = state.body,
isAccountEnabled = isAccountEnabled,
portfolioBlock = portfolioBlock,
relatedNews = state.relatedNews,
)
@ -322,7 +318,6 @@ private fun MarketsTokenDetailsContent_Preview(
state = params,
backgroundColor = TangemTheme.colors.background.tertiary,
portfolioBlock = {},
isAccountEnabled = true,
)
}
}

View file

@ -2,34 +2,27 @@ package com.tangem.features.feed.ui.market.detailed.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleCard
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.items.DescriptionItem
import com.tangem.core.ui.components.items.DescriptionPlaceholder
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.feed.components.NewsSlider
import com.tangem.features.feed.ui.feed.state.NewsSliderCallbacks
import com.tangem.features.feed.ui.feed.state.NewsSliderConfig
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM
import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM.RelatedNews
private const val FOURTH_ITEM_INDEX = 3
@Suppress("CanBeNonNullable") // TODO will be removed after [REDACTED_JIRA]
@Suppress("CanBeNonNullable")
internal fun LazyListScope.tokenMarketDetailsBody(
state: MarketsTokenDetailsUM.Body,
isAccountEnabled: Boolean,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
relatedNews: RelatedNews,
) {
@ -45,9 +38,7 @@ internal fun LazyListScope.tokenMarketDetailsBody(
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
aboutCoinHeader()
loadingInfoBlocks()
}
@ -66,9 +57,7 @@ internal fun LazyListScope.tokenMarketDetailsBody(
relatedNews(relatedNews)
}
if (isAccountEnabled) {
aboutCoinHeader()
}
aboutCoinHeader()
infoBlocksList(state.infoBlocks)
}
@ -207,13 +196,6 @@ private fun LazyListScope.loadingInfoBlocks() {
private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
item("related-news") {
val listState = rememberLazyListState()
val articlesReadStatus = remember(relatedNews.articles) {
relatedNews.articles.map { it.isViewed }
}
LaunchedEffect(articlesReadStatus) {
listState.requestScrollToItem(0)
}
Column(
modifier = Modifier
.fillMaxWidth()
@ -231,35 +213,18 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) {
color = TangemTheme.colors.text.primary1,
)
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = listState,
) {
itemsIndexed(
items = relatedNews.articles,
key = { index, article -> article.id },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = relatedNews.onScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { relatedNews.onArticledClicked(article.id) },
modifier = articleModifier
.heightIn(min = 164.dp)
.width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
}
NewsSlider(
NewsSliderConfig(
callbacks = NewsSliderCallbacks(
onOpenAllNews = {}, // not applicable here
onSliderScroll = relatedNews.onScroll,
onSliderEndReached = {}, // not applicable here
onArticleClick = relatedNews.onArticledClicked,
),
content = relatedNews.articles,
shouldShowSeeAllNewsItem = false,
),
)
}
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.features.feed.ui.market.detailed.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.StateEvent

View file

@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.tangem.common.ui.news.ArticleHeader
import com.tangem.features.feed.ui.feed.components.articles.ArticleHeader
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.buttons.common.TangemButtonSize

View file

@ -1,23 +1,45 @@
package com.tangem.features.feed.ui.news.details.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
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
@Composable
fun NewsDetailsPlaceholder(background: Color, modifier: Modifier = Modifier) {
if (LocalRedesignEnabled.current) {
NewsDetailsPlaceholderV2(background, modifier)
} else {
NewsDetailsPlaceholderV1(background, modifier)
}
}
@Suppress("LongMethod")
@Composable
private fun NewsDetailsPlaceholderV1(background: Color, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().background(background).padding(16.dp),
modifier = modifier
.fillMaxSize()
.background(background)
.padding(16.dp),
) {
RectangleShimmer(modifier = Modifier.size(width = 112.dp, height = 20.dp))
SpacerH(8.dp)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(28.dp),
modifier = Modifier
.fillMaxWidth()
.height(28.dp),
)
SpacerH(4.dp)
RectangleShimmer(modifier = Modifier.size(height = 28.dp, width = 208.dp))
@ -29,29 +51,178 @@ fun NewsDetailsPlaceholder(background: Color, modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp),
modifier = Modifier
.fillMaxWidth()
.height(20.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp),
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 30.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp),
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 30.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 24.dp),
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 24.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 70.dp),
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 70.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 30.dp),
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 30.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 96.dp),
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 96.dp),
)
RectangleShimmer(
modifier = Modifier.fillMaxWidth().height(20.dp).padding(end = 100.dp),
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 100.dp),
)
}
}
}
@Suppress("LongMethod")
@Composable
private fun NewsDetailsPlaceholderV2(background: Color, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(background)
.padding(16.dp),
) {
Row(
modifier = Modifier.height(50.dp),
horizontalArrangement = Arrangement.spacedBy(30.dp),
) {
Column {
RectangleShimmer(
modifier = Modifier.size(width = 50.dp, height = 20.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(10.dp)
RectangleShimmer(
modifier = Modifier.size(width = 90.dp, height = 18.dp),
radius = TangemTheme.dimens2.x25,
)
}
VerticalDivider(color = TangemTheme.colors2.border.neutral.primary)
Column {
RectangleShimmer(
modifier = Modifier.size(width = 50.dp, height = 20.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(10.dp)
RectangleShimmer(
modifier = Modifier.size(width = 90.dp, height = 18.dp),
radius = TangemTheme.dimens2.x25,
)
}
}
SpacerH(36.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(12.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.padding(end = 106.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(36.dp)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RectangleShimmer(
modifier = Modifier.size(width = 98.dp, height = 36.dp),
radius = TangemTheme.dimens2.x25,
)
RectangleShimmer(
modifier = Modifier.size(width = 98.dp, height = 36.dp),
radius = TangemTheme.dimens2.x25,
)
}
SpacerH(20.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 22.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(12.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 66.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(12.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 18.dp),
radius = TangemTheme.dimens2.x25,
)
SpacerH(12.dp)
RectangleShimmer(
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
.padding(end = 46.dp),
radius = TangemTheme.dimens2.x25,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun NewsDetailsPlaceholderPreviewV1() {
TangemThemePreview {
NewsDetailsPlaceholder(background = TangemTheme.colors.background.tertiary)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun NewsDetailsPlaceholderPreviewV2() {
TangemThemePreviewRedesign {
NewsDetailsPlaceholder(background = TangemTheme.colors2.surface.level3)
}
}

View file

@ -0,0 +1,126 @@
package com.tangem.features.feed.ui.news.details.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun QuickRecap(content: String, modifier: Modifier = Modifier) {
if (LocalRedesignEnabled.current) {
QuickRecapV2(content, modifier)
} else {
QuickRecapV1(content, modifier)
}
}
@Composable
private fun QuickRecapV1(content: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier.height(IntrinsicSize.Min),
) {
VerticalDivider(
modifier = Modifier
.fillMaxHeight()
.padding(start = 8.dp),
thickness = 2.dp,
color = TangemTheme.colors.stroke.primary,
)
Column(modifier = Modifier.padding(start = 20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.ic_quick_recap_16),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResourceSafe(R.string.news_quick_recap),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.accent,
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = content,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
@Composable
private fun QuickRecapV2(content: String, modifier: Modifier = Modifier) {
Column(modifier = modifier.height(IntrinsicSize.Min)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20),
contentDescription = null,
)
SpacerW(2.dp)
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle().copy(
brush = Brush.linearGradient(
GRADIENT_START to Color(LINEAR_GRADIENT_FIRST_PART),
GRADIENT_END to Color(LINEAR_GRADIENT_SECOND_PART),
),
),
) {
append(stringResourceSafe(R.string.news_quick_recap))
}
},
style = TangemTheme.typography2.bodyRegular14,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
SpacerH(10.dp)
Box {
VerticalDivider(
modifier = Modifier.fillMaxHeight(),
thickness = 2.dp,
color = Color(QUICK_RECAP_DIVIDER_COLOR),
)
Text(
modifier = Modifier.padding(start = 16.dp),
text = content,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
private const val QUICK_RECAP_DIVIDER_COLOR = 0xFFA99FFF
private const val LINEAR_GRADIENT_FIRST_PART = 0xFFA3A0FF
private const val LINEAR_GRADIENT_SECOND_PART = 0xFFF79DFF
private const val GRADIENT_START = 0f
private const val GRADIENT_END = 0.5f

View file

@ -12,13 +12,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.chip.Chip
import com.tangem.core.ui.components.chip.entity.ChipUM
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.ds.tabs.TangemTab
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.news.list.components.NewsListLazyColumn
import com.tangem.features.feed.ui.news.list.state.NewsListState
@ -29,6 +31,7 @@ import kotlinx.collections.immutable.toImmutableSet
@Composable
internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
val isRedesignEnabled = LocalRedesignEnabled.current
val lazyListState = rememberLazyListState()
Column(
@ -44,7 +47,17 @@ internal fun NewsListContent(state: NewsListUM, modifier: Modifier = Modifier) {
items = state.filters,
key = { it.id },
) { filter ->
Chip(state = filter)
if (isRedesignEnabled) {
TangemTab(
text = filter.text,
isChecked = filter.isSelected,
onCheckedChange = {
filter.onClick()
},
)
} else {
Chip(state = filter)
}
}
}

View file

@ -16,9 +16,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.common.ui.news.DefaultLoadingArticle
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.TangemBlockCardColors
@ -112,7 +112,9 @@ private fun Content(
key = ArticleConfigUM::id,
) { article ->
ArticleCard(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.heightIn(min = 152.dp)
.fillMaxWidth(),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
articleConfigUM = article,
onArticleClick = {

View file

@ -1,7 +1,7 @@
package com.tangem.features.feed.ui.news.list.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.components.chip.entity.ChipUM
import kotlinx.collections.immutable.ImmutableList

View file

@ -9,7 +9,6 @@ import dagger.assisted.AssistedInject
/**
* Mocking it for release/external builds to exclude SumSub dependency
* This will never be called if the FT [isTangemPayEnabled] is off
*/
@Suppress("UnusedPrivateProperty")
internal class MockKycComponent @AssistedInject constructor(

View file

@ -1,6 +1,7 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.managetokens.component
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
enum class ManageTokensSource(val analyticsName: String) {
STORIES(analyticsName = "Stories"),
@ -12,18 +13,15 @@ enum class ManageTokensSource(val analyticsName: String) {
}
sealed interface ManageTokensMode {
data class Wallet(val userWalletId: UserWalletId) : ManageTokensMode
data class Account(val accountId: AccountId) : ManageTokensMode
data class Account(val accountId: AccountId) : ManageTokensMode {
constructor(userWalletId: UserWalletId) : this(AccountId.forMainCryptoPortfolio(userWalletId))
}
data object None : ManageTokensMode
}
sealed interface AddCustomTokenMode {
@Serializable
data class AddCustomTokenMode(val accountId: AccountId) {
val userWalletId: UserWalletId = accountId.userWalletId
val userWalletId: UserWalletId
data class Wallet(override val userWalletId: UserWalletId) : AddCustomTokenMode
data class Account(val accountId: AccountId) : AddCustomTokenMode {
override val userWalletId: UserWalletId = accountId.userWalletId
}
constructor(userWalletId: UserWalletId) : this(AccountId.forMainCryptoPortfolio(userWalletId))
}

View file

@ -4,47 +4,21 @@
<CurrentIssues>
<ID>BooleanPropertyNaming:AddCustomTokenUM.kt$AddCustomTokenUM$val showBackButton: Boolean</ID>
<ID>BooleanPropertyNaming:CustomCurrencyValidator.kt$CustomCurrencyValidator.Status.Validated$val fillForm: Boolean</ID>
<ID>BooleanPropertyNaming:CustomTokenFormModel.kt$CustomTokenFormModel$val needColdWalletInteraction = coldWalletAndHasMissedDerivationsUseCase.invoke( userWalletId = params.mode.userWalletId, networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value), )</ID>
<ID>BooleanPropertyNaming:ManageTokensListManager.kt$ManageTokensListManager$val loadUserTokensFromRemote = when (mode) { is ManageTokensMode.Wallet, is ManageTokensMode.Account, -&gt; source == ManageTokensSource.ONBOARDING ManageTokensMode.None, -&gt; false }</ID>
<ID>BooleanPropertyNaming:ManageTokensModel.kt$ManageTokensModel$val needToInteractWithColdWallet = useCasesFacade.needColdWalletInteraction(networks)</ID>
<ID>BooleanPropertyNaming:ManageTokensUM.kt$ManageTokensUM.ManageContent$val needToInteractWithColdWallet: Boolean</ID>
<ID>BooleanPropertyNaming:OnboardingManageTokensModel.kt$OnboardingManageTokensModel$val showTangemIcon = useCasesFacade.needColdWalletInteraction(network = network)</ID>
<ID>BooleanPropertyNaming:OnboardingManageTokensUM.kt$OnboardingManageTokensUM.ActionButtonConfig$abstract val showProgress: Boolean</ID>
<ID>BooleanPropertyNaming:OnboardingManageTokensUM.kt$OnboardingManageTokensUM.ActionButtonConfig.Continue$val showTangemIcon: Boolean</ID>
<ID>MaxChainedCallsOnSameLine:ChooseManagedTokensModel.kt$ChooseManagedTokensModel$params.initialCurrency.network.id.rawId.value</ID>
<ID>MultilineLambdaItParameter:ChooseManagedTokenContent.kt${ add( CurrencyItemUM.Basic( id = ManagedCryptoCurrency.ID( value = "ID+$it", ), name = "Bitcoin", symbol = "BTC", icon = CurrencyIconState.Loading, networks = CurrencyItemUM.Basic.NetworksUM.Collapsed, onExpandClick = {}, ), ) }</ID>
<ID>MultilineLambdaItParameter:ChooseManagedTokensModel.kt$ChooseManagedTokensModel${ it.copy( notificationUM = null, ) }</ID>
<ID>MultilineLambdaItParameter:CurrencyItemMapper.kt${ it.toCurrencyNetworkModel( isSelected = it.network in addedIn, isEditable = false, onSelectedStateChange = { _, _ -&gt; }, onLongTap = { _ -&gt; }, ) }</ID>
<ID>MultilineLambdaItParameter:CustomCurrencyFormOperations.kt${ it[Field.CONTRACT_ADDRESS] = it.getValue(Field.CONTRACT_ADDRESS).copy( error = when (exception) { CustomTokenFormValidationException.ContractAddress.Invalid -&gt; { resourceReference(R.string.custom_token_creation_error_invalid_contract_address) } }, ) }</ID>
<ID>MultilineLambdaItParameter:CustomCurrencyFormOperations.kt${ it[Field.DECIMALS] = it.getValue(Field.DECIMALS).copy( error = when (exception) { is CustomTokenFormValidationException.Decimals.Empty -&gt; { null // Should not display this error } is CustomTokenFormValidationException.Decimals.Invalid -&gt; { resourceReference( R.string.custom_token_creation_error_wrong_decimals, wrappedList(ValidateTokenFormUseCase.MAX_DECIMALS), ) } }, ) }</ID>
<ID>MultilineLambdaItParameter:CustomTokenFormContent.kt$PreviewCustomTokenFormComponentProvider${ it[Field.CONTRACT_ADDRESS] = it[Field.CONTRACT_ADDRESS]!!.copy( label = stringReference("Contract address"), value = "0x1234567890", error = stringReference("Contract address is invalid"), placeholder = stringReference("0x1234567890"), ) }</ID>
<ID>MultilineLambdaItParameter:CustomTokenFormContent.kt$PreviewCustomTokenFormComponentProvider${ it[Field.CONTRACT_ADDRESS] = it[Field.CONTRACT_ADDRESS]!!.copy( label = stringReference("Contract address"), value = "0x1234567890", placeholder = stringReference("0x1234567890"), ) }</ID>
<ID>MultilineLambdaItParameter:CustomTokenFormModel.kt$CustomTokenFormModel${ Timber.e(it, "Failed to add currency") showErrorDialog() return@resource }</ID>
<ID>MultilineLambdaItParameter:CustomTokenFormModel.kt$CustomTokenFormModel${ Timber.e(it, "Failed to derive public keys") showErrorDialog() return@resource }</ID>
<ID>MultilineLambdaItParameter:CustomTokenSelectorModel.kt$CustomTokenSelectorModel${ when (it) { is AccountStatus.CryptoPortfolio -&gt; it.sameNodeAndNotMain() } }</ID>
<ID>MultilineLambdaItParameter:ManageTokensListManager.kt$ManageTokensListManager${ Timber.e( it, """ Failed to check currency unsupported state |- Mode: $mode |- Source Network: $sourceNetwork """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) null }</ID>
<ID>MultilineLambdaItParameter:ManageTokensListManager.kt$ManageTokensListManager${ Timber.e( it, """ Failed to check linked tokens |- Mode: $mode |- Network ID: ${network.id} """.trimIndent(), ) val message = SnackbarMessage( message = it.localizedMessage ?.let(::stringReference) ?: resourceReference(R.string.common_error), ) messageSender.send(message) false }</ID>
<ID>MultilineLambdaItParameter:ManageTokensModel.kt$ManageTokensModel${ Timber.e(it, "Failed to save changes") return@resource }</ID>
<ID>MultilineLambdaItParameter:ManageTokensUseCasesFacade.kt$ManageTokensUseCasesFacade${ it is CryptoCurrency.Token &amp;&amp; it.network.backendId == network.backendId &amp;&amp; it.network.derivationPath == network.derivationPath }</ID>
<ID>MultilineLambdaItParameter:OnboardingManageTokensModel.kt$OnboardingManageTokensModel${ Timber.e(it, "Failed to save changes") return@resource }</ID>
<ID>MultilineLambdaItParameter:PreviewManageTokensComponent.kt$PreviewManageTokensComponent${ it.fastForEachIndexed { index, network -&gt; if (index == networkIndex) { it[index] = network.copy( iconResId = if (isSelected) { R.drawable.img_eth_22 } else { R.drawable.ic_eth_16 }, isSelected = isSelected, ) } } }</ID>
<ID>NamedArguments:ChangedCurrenciesManager.kt$ChangedCurrenciesManager$updateChangedItems(currency, network, currenciesToAdd, currenciesToRemove)</ID>
<ID>NamedArguments:ChangedCurrenciesManager.kt$ChangedCurrenciesManager$updateChangedItems(currency, network, currenciesToRemove, currenciesToAdd)</ID>
<ID>NamedArguments:ChooseManagedTokensModel.kt$ChooseManagedTokensModel$TokenSearched( params.analyticsCategoryName, token = null, blockchain = null, isTokenChosen = false, )</ID>
<ID>NamedArguments:ManageTokensListManager.kt$ManageTokensListManager$selectNetwork(currencyBatch.key, currency, networkId, isSelected)</ID>
<ID>NamedArguments:ManageTokensListManager.kt$ManageTokensListManager$sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = false)</ID>
<ID>NamedArguments:ManageTokensListManager.kt$ManageTokensListManager$sendSelectCurrencyAction(batchKey, currency.id, network, isSelected = true)</ID>
<ID>NestedScopeFunctions:CustomTokenSelectorModel.kt$CustomTokenSelectorModel$let { recognizer.recognize(it) }</ID>
<ID>NullableBooleanCheck:CustomCurrencyFormOperations.kt$this.tokenForm?.wasFilled ?: false</ID>
<ID>PropertyUsedBeforeDeclaration:ChooseManagedTokensModel.kt$ChooseManagedTokensModel$bottomSheetNavigation</ID>
<ID>PropertyUsedBeforeDeclaration:ChooseManagedTokensModel.kt$ChooseManagedTokensModel$uiState</ID>
<ID>UnsafeCallOnNullableType:CustomTokenFormContent.kt$PreviewCustomTokenFormComponentProvider$it[Field.CONTRACT_ADDRESS]!!</ID>
<ID>UnsafeCallOnNullableType:PreviewAddCustomTokenComponent.kt$PreviewAddCustomTokenComponent$config.selectedDerivationPath!!</ID>
<ID>UnsafeCallOnNullableType:PreviewAddCustomTokenComponent.kt$PreviewAddCustomTokenComponent$config.selectedNetwork!!</ID>
<ID>UseEmptyCounterpart:CustomTokenAnalyticsEvent.kt$CustomTokenAnalyticsEvent$mapOf()</ID>
<ID>UseEmptyCounterpart:ManageTokensAnalyticEvent.kt$ManageTokensAnalyticEvent$mapOf()</ID>
<ID>UseOrEmpty:ChangedCurrenciesManager.kt$ChangedCurrenciesManager$items[currency] ?: emptySet()</ID>
<ID>UseOrEmpty:PreviewCustomTokenSelectorComponent.kt$PreviewCustomTokenSelectorComponent$d.id?.rawId?.value ?: ""</ID>
<ID>VarCouldBeVal:CustomTokenFormModel.kt$CustomTokenFormModel$private var useCasesFacade: CustomTokenFormUseCasesFacade = customTokenFormUseCasesFacadeFactory.create(params.mode)</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -17,8 +17,6 @@ import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.notifications.SetShouldShowNotificationUseCase
import com.tangem.features.managetokens.choosetoken.entity.ChooseManageTokensBottomSheetConfig
import com.tangem.features.managetokens.choosetoken.entity.ChooseManagedTokenUM
@ -45,7 +43,6 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.collections.isNotEmpty
@Suppress("LongParameterList")
@ModelScoped
@ -55,7 +52,6 @@ internal class ChooseManagedTokensModel @Inject constructor(
private val uiMessageSender: UiMessageSender,
private val setShouldShowNotificationUseCase: SetShouldShowNotificationUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
accountsFeatureToggles: AccountsFeatureToggles,
paramsContainer: ParamsContainer,
manageTokensUseCasesFacadeFactory: ManageTokensUseCasesFacade.Factory,
manageTokensListManagerFactory: ManageTokensListManager.Factory,
@ -63,16 +59,15 @@ internal class ChooseManagedTokensModel @Inject constructor(
private val params: ChooseManagedTokensComponent.Params = paramsContainer.require()
private val manageTokensMode = if (accountsFeatureToggles.isFeatureEnabled) {
val accountId = AccountId.forMainCryptoPortfolio(userWalletId = params.userWalletId)
ManageTokensMode.Account(accountId = accountId)
} else {
ManageTokensMode.Wallet(params.userWalletId)
}
private val manageTokensMode = ManageTokensMode.Account(params.userWalletId)
private val useCasesFacade: ManageTokensUseCasesFacade = manageTokensUseCasesFacadeFactory
.create(mode = manageTokensMode)
val bottomSheetNavigation: SlotNavigation<ChooseManageTokensBottomSheetConfig> = SlotNavigation()
val uiState: StateFlow<ChooseManagedTokenUM>
field = MutableStateFlow<ChooseManagedTokenUM>(createReadContentModel())
private val manageTokensListManager = manageTokensListManagerFactory.create(
scope = modelScope,
source = ManageTokensSource.SEND_VIA_SWAP,
@ -91,10 +86,6 @@ internal class ChooseManagedTokensModel @Inject constructor(
},
)
val bottomSheetNavigation: SlotNavigation<ChooseManageTokensBottomSheetConfig> = SlotNavigation()
val uiState: StateFlow<ChooseManagedTokenUM>
field = MutableStateFlow<ChooseManagedTokenUM>(createReadContentModel())
init {
manageTokensListManager.uiItems
.onEach { items -> updateItems(items) }
@ -146,11 +137,7 @@ internal class ChooseManagedTokensModel @Inject constructor(
private fun removeNotification() {
modelScope.launch {
setShouldShowNotificationUseCase(NotificationId.SendViaSwapTokenSelectorNotification.key, false)
uiState.update {
it.copy(
notificationUM = null,
)
}
uiState.update { it.copy(notificationUM = null) }
}
}
@ -161,7 +148,7 @@ internal class ChooseManagedTokensModel @Inject constructor(
if (!new.readContent.search.isActive && old.readContent.search.isActive) {
analyticsEventHandler.send(
CommonManageTokensAnalyticEvents.TokenSearched(
params.analyticsCategoryName,
categoryName = params.analyticsCategoryName,
token = null,
blockchain = null,
isTokenChosen = false,
@ -195,8 +182,9 @@ internal class ChooseManagedTokensModel @Inject constructor(
val isToken = currency.id.value == params.initialCurrency.id.rawCurrencyId?.value
// Ensure that initial token network is filtered out and network list is empty
val paramsRawId = params.initialCurrency.network.id.rawId
val isEmptyNetworks = availableNetworks?.networks?.filterNot { network ->
network.id == params.initialCurrency.network.id.rawId.value
network.id == paramsRawId.value
}.isNullOrEmpty()
// Filter out currency from display

View file

@ -16,11 +16,11 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.features.managetokens.component.AddCustomTokenComponent
import com.tangem.features.managetokens.component.AddCustomTokenMode
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.model.ManageTokensModel
import com.tangem.features.managetokens.ui.ManageTokensScreen
@ -38,7 +38,7 @@ internal class DefaultManageTokensComponent @AssistedInject constructor(
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = ManageTokensBottomSheetConfig.serializer(),
serializer = AccountId.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@ -57,13 +57,10 @@ internal class DefaultManageTokensComponent @AssistedInject constructor(
}
private fun bottomSheetChild(
config: ManageTokensBottomSheetConfig,
accountId: AccountId,
componentContext: ComponentContext,
): ComposableBottomSheetComponent {
val mode = when (config) {
is ManageTokensBottomSheetConfig.AddWalletCustomToken -> AddCustomTokenMode.Wallet(config.userWalletId)
is ManageTokensBottomSheetConfig.AddAccountCustomToken -> AddCustomTokenMode.Account(config.accountId)
}
val mode = AddCustomTokenMode(accountId)
return addCustomTokenComponentFactory.create(
context = childByContext(componentContext),
params = AddCustomTokenComponent.Params(

View file

@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
internal class PreviewAddCustomTokenComponent(
initialState: AddCustomTokenConfig = AddCustomTokenConfig(
mode = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")),
mode = AddCustomTokenMode(UserWalletId(stringValue = "321")),
step = AddCustomTokenConfig.Step.INITIAL_NETWORK_SELECTOR,
),
) : AddCustomTokenComponent {
@ -61,8 +61,8 @@ internal class PreviewAddCustomTokenComponent(
PreviewCustomTokenSelectorComponent(
params = CustomTokenSelectorComponent.Params.DerivationPathSelector(
mode = config.mode,
selectedNetwork = config.selectedNetwork!!,
selectedDerivationPath = config.selectedDerivationPath!!,
selectedNetwork = requireNotNull(config.selectedNetwork),
selectedDerivationPath = requireNotNull(config.selectedDerivationPath),
onDerivationPathSelected = { _, _ -> },
),
).Content(modifier)

View file

@ -19,7 +19,7 @@ import kotlinx.collections.immutable.toImmutableList
internal class PreviewCustomTokenSelectorComponent(
private val params: Params = Params.NetworkSelector(
mode = AddCustomTokenMode.Wallet(UserWalletId(stringValue = "321")),
mode = AddCustomTokenMode(UserWalletId(stringValue = "321")),
selectedNetwork = null,
onNetworkSelected = {},
),
@ -39,7 +39,7 @@ internal class PreviewCustomTokenSelectorComponent(
)
DerivationPathUM(
id = d.id?.rawId?.value ?: "",
id = d.id?.rawId?.value.orEmpty(),
value = d.value.value.orEmpty(),
networkName = stringReference(d.name),
isSelected = d.value == params.selectedDerivationPath?.value,

View file

@ -40,9 +40,7 @@ internal class PreviewManageTokensComponent(
popBack = {},
items = items,
topBar = when (params.mode) {
is ManageTokensMode.Account,
is ManageTokensMode.Wallet,
-> ManageTokensTopBarUM.ManageContent(
is ManageTokensMode.Account -> ManageTokensTopBarUM.ManageContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = {},
endButton = TopAppBarButtonUM.Icon(
@ -206,10 +204,10 @@ internal class PreviewManageTokensComponent(
is CurrencyItemUM.Basic -> {
val updatedNetworks = (item.networks as? CurrencyItemUM.Basic.NetworksUM.Expanded)
?.copy(
networks = item.networks.networks.toPersistentList().mutate {
it.fastForEachIndexed { index, network ->
networks = item.networks.networks.toPersistentList().mutate { networks ->
networks.fastForEachIndexed { index, network ->
if (index == networkIndex) {
it[index] = network.copy(
networks[index] = network.copy(
iconResId = if (isSelected) {
R.drawable.img_eth_22
} else {

View file

@ -1,19 +0,0 @@
package com.tangem.features.managetokens.entity.managetokens
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
@Serializable
internal sealed class ManageTokensBottomSheetConfig {
@Serializable
data class AddWalletCustomToken(
val userWalletId: UserWalletId,
) : ManageTokensBottomSheetConfig()
@Serializable
data class AddAccountCustomToken(
val accountId: AccountId,
) : ManageTokensBottomSheetConfig()
}

View file

@ -181,7 +181,7 @@ internal class CustomTokenFormModel @Inject constructor(
isAlreadyAdded: Boolean,
isCustom: Boolean,
) = modelScope.launch {
val needColdWalletInteraction = coldWalletAndHasMissedDerivationsUseCase.invoke(
val isNeedColdWalletInteraction = coldWalletAndHasMissedDerivationsUseCase.invoke(
userWalletId = params.mode.userWalletId,
networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value),
)
@ -195,7 +195,7 @@ internal class CustomTokenFormModel @Inject constructor(
clearNotifications = true,
clearFieldErrors = true,
disableSecondaryFields = !isCustom,
walletInteractionIcon = R.drawable.ic_tangem_24.takeIf { needColdWalletInteraction },
walletInteractionIcon = R.drawable.ic_tangem_24.takeIf { isNeedColdWalletInteraction },
)
if (fillForm) {
@ -355,11 +355,6 @@ internal class CustomTokenFormModel @Inject constructor(
return@resource
}
useCasesFacade.derivePublicKeysUseCase(listOf(currency)).getOrElse {
showErrorDialog(IllegalStateException("Failed to derive public keys"))
return@resource
}
useCasesFacade.addCryptoCurrenciesUseCase(currency).getOrElse { throwable ->
showErrorDialog(throwable)
return@resource

View file

@ -15,7 +15,6 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.managetokens.GetSupportedNetworksUseCase
import com.tangem.domain.models.account.Account
@ -52,7 +51,6 @@ internal class CustomTokenSelectorModel @Inject constructor(
private val getSupportedNetworksUseCase: GetSupportedNetworksUseCase,
private val messageSender: UiMessageSender,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val accountsFeatureToggles: AccountsFeatureToggles,
paramsContainer: ParamsContainer,
) : Model() {
@ -171,7 +169,7 @@ internal class CustomTokenSelectorModel @Inject constructor(
}
private suspend fun getSupportedNetworks(mode: AddCustomTokenMode): List<Network> {
return getSupportedNetworksUseCase(mode.userWalletId).getOrElse { e ->
return getSupportedNetworksUseCase(mode.userWalletId).getOrElse { _ ->
val message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error))
messageSender.send(message)
@ -194,19 +192,23 @@ internal class CustomTokenSelectorModel @Inject constructor(
fun selectCustomDerivationPath(value: SelectedDerivationPath) {
when (params) {
is NetworkSelector -> return
is DerivationPathSelector -> if (accountsFeatureToggles.isFeatureEnabled) {
params.checkAccountDerivation(value)
} else {
params.onDerivationPathSelected(value, null)
}
is DerivationPathSelector -> params.checkAccountDerivation(value)
}
}
private fun DerivationPathSelector.checkAccountDerivation(derivationPath: SelectedDerivationPath) =
modelScope.launch {
val account = derivationPath.id
?.let { Blockchain.fromId(it.rawId.value) }?.let(::AccountNodeRecognizer)
?.let { recognizer -> derivationPath.value.value?.let { recognizer.recognize(it) } }
?.let { Blockchain.fromId(it.rawId.value) }
?.let(::AccountNodeRecognizer)
?.let { recognizer ->
val derivationPathValue = derivationPath.value.value
if (derivationPathValue != null) {
recognizer.recognize(derivationPathValue)
} else {
null
}
}
?.let { accountNode ->
fun AccountStatus.CryptoPortfolio.sameNodeAndNotMain() = !this.account.isMainAccount &&
this.account.derivationIndex.value.toLong() == accountNode

View file

@ -18,12 +18,12 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.account.AccountId
import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent
import com.tangem.features.managetokens.analytics.ManageTokensAnalyticEvent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
import com.tangem.features.managetokens.entity.item.CurrencyItemUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensBottomSheetConfig
import com.tangem.features.managetokens.entity.managetokens.ManageTokensTopBarUM
import com.tangem.features.managetokens.entity.managetokens.ManageTokensUM
import com.tangem.features.managetokens.impl.R
@ -66,7 +66,7 @@ internal class ManageTokensModel @Inject constructor(
)
val state: MutableStateFlow<ManageTokensUM> = MutableStateFlow(getInitialState())
val bottomSheetNavigation: SlotNavigation<ManageTokensBottomSheetConfig> = SlotNavigation()
val bottomSheetNavigation: SlotNavigation<AccountId> = SlotNavigation()
init {
manageTokensListManager.uiItems
@ -101,15 +101,12 @@ internal class ManageTokensModel @Inject constructor(
analyticsEventHandler.send(ManageTokensAnalyticEvent.ScreenOpened(params.source))
return when (params.mode) {
is ManageTokensMode.Wallet,
is ManageTokensMode.Account,
-> createManageContentModel()
is ManageTokensMode.Account -> createManageContentModel()
ManageTokensMode.None -> createReadContentModel()
}
}
private fun getTopBarInitialState(): ManageTokensTopBarUM = when (params.mode) {
is ManageTokensMode.Wallet -> manageContentTopBar()
is ManageTokensMode.Account -> ManageTokensTopBarUM.ReadContent(
title = resourceReference(id = R.string.main_manage_tokens),
onBackButtonClick = router::pop,
@ -196,9 +193,7 @@ internal class ManageTokensModel @Inject constructor(
state.update { it.copySealed(topBar = manageContentTopBar()) }
}
}
ManageTokensMode.None,
is ManageTokensMode.Wallet,
-> Unit // use init state
ManageTokensMode.None -> Unit // use init state
}
}
@ -299,12 +294,12 @@ internal class ManageTokensModel @Inject constructor(
.flatten()
.toSet()
.associate { network -> network.backendId to network.derivationPath.value }
val needToInteractWithColdWallet = useCasesFacade.needColdWalletInteraction(networks)
val isNeedToInteractWithColdWallet = useCasesFacade.needColdWalletInteraction(networks)
state.update { state ->
state.copySealed(
hasChanges = currenciesToAdd.isNotEmpty() || currenciesToRemove.isNotEmpty(),
needToInteractWithColdWallet = needToInteractWithColdWallet,
needToInteractWithColdWallet = isNeedToInteractWithColdWallet,
)
}
}
@ -324,12 +319,8 @@ internal class ManageTokensModel @Inject constructor(
private fun navigateToAddCustomToken() {
analyticsEventHandler.send(CustomTokenAnalyticsEvent.ButtonCustomToken(params.source))
when (val portfolio = params.mode) {
is ManageTokensMode.Wallet ->
bottomSheetNavigation
.activate(ManageTokensBottomSheetConfig.AddWalletCustomToken(portfolio.userWalletId))
is ManageTokensMode.Account ->
bottomSheetNavigation
.activate(ManageTokensBottomSheetConfig.AddAccountCustomToken(portfolio.accountId))
bottomSheetNavigation.activate(portfolio.accountId)
ManageTokensMode.None -> Unit
}
}
@ -347,8 +338,8 @@ internal class ManageTokensModel @Inject constructor(
useCasesFacade.saveManagedTokensUseCase(
currenciesToAdd = manageTokensListManager.currenciesToAdd.value,
currenciesToRemove = manageTokensListManager.currenciesToRemove.value,
).getOrElse {
Timber.e(it, "Failed to save changes")
).getOrElse { throwable ->
Timber.e(throwable, "Failed to save changes")
return@resource
}

Some files were not shown because too many files have changed in this diff Show more