Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-06 20:37:03 +07:00
commit e52f9de341
987 changed files with 20247 additions and 8171 deletions

View file

@ -66,7 +66,6 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(deps.firebase.crashlytics)
/** DI */

View file

@ -25,10 +25,10 @@ import com.tangem.features.account.createedit.error.AccountFeatureError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ -147,7 +147,7 @@ internal class ArchivedAccountListModel @Inject constructor(
private fun logError(error: AccountFeatureError, params: Map<String, String> = emptyMap()) {
val exception = IllegalStateException(error.toString())
Timber.e(exception)
TangemLogger.e("Error", exception)
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(exception = exception, params = params),

View file

@ -8,8 +8,8 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.usecase.ArchivedAccountList
import com.tangem.domain.models.account.AccountId
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import javax.inject.Inject
internal class AccountArchivedUMBuilder @Inject constructor() {
@ -49,7 +49,7 @@ internal class AccountArchivedUMBuilder @Inject constructor() {
onCloseClick: () -> Unit,
getArchivedAccounts: () -> Unit,
): AccountArchivedUM.Error {
Timber.e(throwable)
TangemLogger.e("Error", throwable)
return AccountArchivedUM.Error(
onCloseClick = onCloseClick,
onRetryClick = { getArchivedAccounts() },

View file

@ -42,11 +42,11 @@ import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.
import com.tangem.features.account.createedit.entity.AccountCreateEditUMBuilder.Companion.updateName
import com.tangem.features.account.createedit.error.AccountFeatureError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ModelScoped
@ -242,7 +242,7 @@ internal class AccountCreateEditModel @Inject constructor(
private fun onNameChange(name: AccountNameUM) {
val isNotEmptyCustomName = (name as? AccountNameUM.Custom)?.raw?.isNotEmpty() == true
if (!name.isValidName() && isNotEmptyCustomName) {
Timber.d("Invalid account name: $name")
TangemLogger.d("Invalid account name: $name")
return
}
@ -310,7 +310,7 @@ internal class AccountCreateEditModel @Inject constructor(
private fun logError(error: AccountFeatureError, params: Map<String, String> = emptyMap()) {
val exception = IllegalStateException(error.toString())
Timber.e(exception)
TangemLogger.e("Error", exception)
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(exception = exception, params = params),

View file

@ -13,6 +13,7 @@ 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.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -31,6 +32,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.AccountDetailsScreenTestTags
import com.tangem.features.account.details.entity.AccountDetailsUM
@Composable
@ -130,7 +132,8 @@ private fun ManageTokensRow(state: AccountDetailsUM) {
.clip(RoundedCornerShape(TangemTheme.dimens.radius12))
.background(TangemTheme.colors.background.primary)
.clickable(onClick = state.onManageTokensClick)
.padding(all = TangemTheme.dimens.spacing12),
.padding(all = TangemTheme.dimens.spacing12)
.testTag(AccountDetailsScreenTestTags.MANAGE_TOKENS_BUTTON),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {

View file

@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.PortfolioFetcher
@ -179,13 +180,10 @@ internal class PortfolioSelectorModel @Inject constructor(
}
}
portfolio.accountsBalance.accountStatuses.forEach { accountStatus ->
portfolio.accountsBalance.accountStatuses.filterCryptoPortfolio().forEach { accountStatus ->
val isEnabledByFeature = isEnabled(wallet, accountStatus)
val account = accountStatus.account
val accountBalance = when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance
is AccountStatus.Payment -> accountStatus.totalFiatBalance
}
val accountBalance = accountStatus.tokenList.totalFiatBalance
val accountItemUM = AccountPortfolioItemUMConverter(
onClick = { selectorController.selectAccount(account.accountId) },
appCurrency = appCurrency,

View file

@ -14,7 +14,10 @@ interface GiveApprovalComponent : ComposableBottomSheetComponent {
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val amount: String,
val spenderAddress: String,
val subtitle: TextReference,
val amountFooter: TextReference,
val feeFooter: TextReference,
val isHoldToConfirm: Boolean = false,
val isResetApproval: Boolean = false,
val callback: Callback,
)

View file

@ -50,7 +50,6 @@ dependencies {
/** Other */
implementation(deps.decompose)
implementation(deps.decompose.ext.compose)
implementation(deps.timber)
implementation(deps.kotlin.immutable.collections)
implementation(deps.arrow.core)

View file

@ -7,6 +7,7 @@ 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.R
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
@ -23,7 +24,6 @@ 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,
@ -38,6 +38,7 @@ internal class DefaultGiveApprovalComponent @AssistedInject constructor(
params = FeeSelectorParams.FeeSelectorBlockParams(
state = FeeSelectorUM.Loading,
onLoadFee = { model.loadFee() },
onDisableCustomFee = { model.shouldDisableCustomFee() },
onLoadFeeExtended = { selectedFeeToken -> model.loadFeeExtended(selectedFeeToken) },
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
@ -70,24 +71,26 @@ internal class DefaultGiveApprovalComponent @AssistedInject constructor(
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
containerColor = TangemTheme.colors.background.secondary,
titleText = resourceReference(CommonUiR.string.give_permission_title),
containerColor = TangemTheme.colors.background.tertiary,
titleText = resourceReference(
if (uiState.isResetApproval) {
R.string.update_approval_permission_title
} else {
R.string.give_permission_title
},
),
titleAction = TopAppBarButtonUM.Icon(
iconRes = CommonUiR.drawable.ic_information_24,
onClicked = model::showPermissionInfoDialog,
iconRes = R.drawable.ic_close_new_20,
onClicked = model::onCancelClick,
),
) {
GiveApprovalContent(
currency = currency,
subtitle = params.subtitle,
approveType = uiState.approveType,
approveItems = uiState.approveItems,
amountFooter = params.amountFooter,
feeFooter = params.feeFooter,
uiState = uiState,
onChangeApproveType = model::onChangeApproveType,
walletInteractionIcon = uiState.walletInteractionIcon,
isApproveEnabled = uiState.isApproveButtonEnabled,
isApproveLoading = uiState.isApproveLoading,
onApproveClick = model::onApproveClick,
onCancelClick = model::onCancelClick,
onOpenLearnMoreAboutApproveClick = model::onOpenLearnMoreAboutApproveClick,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)

View file

@ -5,9 +5,11 @@ import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionSender.MultipleTransactionSendMode
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.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
@ -15,41 +17,45 @@ 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.navigation.url.UrlOpener
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.AllowanceInfo
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase
import com.tangem.domain.transaction.usecase.GetAllowanceInfoUseCase
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.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeItem
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 com.tangem.utils.logging.TangemLogger
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 java.math.RoundingMode
import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class GiveApprovalModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase,
@ -76,11 +82,15 @@ internal class GiveApprovalModel @Inject constructor(
walletInteractionIcon = walletInterationIcon(userWallet),
isApproveButtonEnabled = false,
isApproveLoading = false,
isHoldToConfirm = params.isHoldToConfirm,
isResetApproval = params.isResetApproval,
),
)
private var feeSelectorUM: FeeSelectorUM = FeeSelectorUM.Loading
private var approvalTxList: Map<TransactionData.Uncompiled, TransactionFee> = emptyMap()
override fun onFeeResult(feeSelectorUM: FeeSelectorUM) {
this.feeSelectorUM = feeSelectorUM
uiState.update { it.copy(isApproveButtonEnabled = feeSelectorUM.isPrimaryButtonEnabled) }
@ -121,47 +131,94 @@ internal class GiveApprovalModel @Inject constructor(
)
}
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> {
return onApprovalTx(
onApprove = { approve ->
getFeeUseCase(
transactionData = approve,
userWallet = userWallet,
network = params.cryptoCurrencyStatus.currency.network,
).onRight { fee ->
approvalTxList = mapOf(approve to fee)
}
},
onResetApprove = { (revokeApproval, approve) ->
getFeeUseCase(
transactionData = revokeApproval,
userWallet = userWallet,
network = params.cryptoCurrencyStatus.currency.network,
).map { revokeFee ->
estimateFeeForResetApproval(
revokeTransactionFee = revokeFee,
revokeApprovalTransaction = revokeApproval,
approvalTransaction = approve,
)
}
},
)
}
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,
)
fun shouldDisableCustomFee(): Boolean {
return approvalTxList.size > 1
}
suspend fun loadFeeExtended(maybeToken: CryptoCurrencyStatus?): Either<GetFeeError, TransactionFeeExtended> {
val approvalTransaction = prepareApprovalTransaction()
.getOrElse { return GetFeeError.DataError(it).left() }
val approve = createApprovalTransactionUseCase(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = params.cryptoCurrencyStatus,
amount = getApprovalAmount(),
contractAddress = (params.cryptoCurrencyStatus.currency as CryptoCurrency.Token).contractAddress,
spenderAddress = params.spenderAddress,
).getOrElse { error ->
TangemLogger.e("Failed to create approveTransaction", error)
return GetFeeError.DataError(error).left()
}
return if (maybeToken == null) {
getFeeForGaslessUseCase(
transactionData = approvalTransaction,
transactionData = approve,
userWallet = userWallet,
network = params.cryptoCurrencyStatus.currency.network,
)
} else {
getFeeForTokenUseCase(
transactionData = approvalTransaction,
transactionData = approve,
userWallet = userWallet,
token = maybeToken.currency,
)
}.onRight { feeExtended ->
approvalTxList = mapOf(approve to feeExtended.transactionFee)
}
}
private fun estimateFeeForResetApproval(
revokeTransactionFee: TransactionFee,
revokeApprovalTransaction: TransactionData.Uncompiled,
approvalTransaction: TransactionData.Uncompiled,
) = when (revokeTransactionFee) {
is TransactionFee.Choosable -> {
val approveFee = revokeTransactionFee.copy(
minimum = revokeTransactionFee.minimum.increaseEthereumGasLimitBy(2.toBigDecimal()),
normal = revokeTransactionFee.normal.increaseEthereumGasLimitBy(2.toBigDecimal()),
priority = revokeTransactionFee.priority.increaseEthereumGasLimitBy(2.toBigDecimal()),
)
approvalTxList = mapOf(
revokeApprovalTransaction to revokeTransactionFee,
approvalTransaction to approveFee,
)
revokeTransactionFee.copy(
minimum = approveFee.minimum + revokeTransactionFee.minimum,
normal = approveFee.normal + revokeTransactionFee.normal,
priority = approveFee.priority + revokeTransactionFee.priority,
)
}
is TransactionFee.Single -> {
val approveFee = revokeTransactionFee.copy(normal = revokeTransactionFee.normal)
approvalTxList = mapOf(
revokeApprovalTransaction to revokeTransactionFee,
approvalTransaction to approveFee,
)
revokeTransactionFee.copy(normal = approveFee.normal + revokeTransactionFee.normal)
}
}
@ -170,38 +227,37 @@ internal class GiveApprovalModel @Inject constructor(
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
val transactions = approvalTxList.map { (tx, fee) ->
tx.copy(
fee = when (feeContent.selectedFeeItem) {
is FeeItem.Fast -> (fee as? TransactionFee.Choosable)?.priority ?: fee.normal
is FeeItem.Market -> fee.normal
is FeeItem.Slow -> (fee as? TransactionFee.Choosable)?.minimum ?: fee.normal
else -> feeContent.selectedFeeItem.fee
},
)
}
return if (isFeeInTokenCurrency) {
createAndSendGaslessTransactionUseCase(
userWallet = userWallet,
transactionData = transactionData,
transactionData = transactions.first(),
fee = feeExtended,
)
} else {
sendTransactionUseCase(
txData = transactionData,
txsData = transactions,
userWallet = userWallet,
network = tokenCurrency.network,
sendMode = MultipleTransactionSendMode.DEFAULT,
)
}.fold(
ifLeft = { error ->
Timber.e("Failed to send approval transaction: $error")
TangemLogger.e("Failed to send approval transaction: $error")
false
},
ifRight = {
@ -240,4 +296,88 @@ internal class GiveApprovalModel @Inject constructor(
null
}
}
private suspend fun <T> onApprovalTx(
onApprove: suspend (TransactionData.Uncompiled) -> Either<GetFeeError, T>,
onResetApprove:
suspend (Pair<TransactionData.Uncompiled, TransactionData.Uncompiled>) -> Either<GetFeeError, T>,
): Either<GetFeeError, T> {
val cryptoCurrencyStatus = params.cryptoCurrencyStatus
val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token
?: return GetFeeError.DataError(IllegalStateException("Currency is not a token")).left()
val amount = params.amount.toBigDecimalOrNull()
?: return GetFeeError.DataError(IllegalArgumentException("Invalid amount format")).left()
val allowance = getAllowanceInfoUseCase(
userWalletId = params.userWalletId,
cryptoCurrency = cryptoCurrencyStatus.currency,
spenderAddress = params.spenderAddress,
requiredAmount = amount,
).getOrElse { error ->
TangemLogger.e("Failed to get allowance info", error)
return GetFeeError.DataError(error).left()
}
val approve = createApprovalTransactionUseCase(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amount = getApprovalAmount(),
contractAddress = tokenCurrency.contractAddress,
spenderAddress = params.spenderAddress,
).getOrElse { error ->
TangemLogger.e("Failed to create approveTransaction", error)
return GetFeeError.DataError(error).left()
}
return if (allowance is AllowanceInfo.ResetNeeded) {
val revokeApproval = createApprovalTransactionUseCase(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
amount = BigDecimal.ZERO,
contractAddress = tokenCurrency.contractAddress,
spenderAddress = params.spenderAddress,
).getOrElse { error ->
TangemLogger.e("Failed to create revoke approveTransaction", error)
return GetFeeError.DataError(error).left()
}
onResetApprove(revokeApproval to approve)
} else {
onApprove(approve)
}
}
private fun Fee.increaseEthereumGasLimitBy(multiplier: BigDecimal): Fee {
if (this !is Fee.Ethereum) return this
val increasedGasPrice = amount.value?.movePointRight(amount.decimals)
?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP)
val increasedGasLimit = gasLimit
.multiply(multiplier.toBigInteger())
val increasedAmount = amount.copy(
value = increasedGasPrice?.multiply(
increasedGasLimit.toBigDecimal().movePointLeft(amount.decimals),
),
)
return when (this) {
is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit)
is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit)
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
}
}
private operator fun Fee.plus(otherFee: Fee): Fee {
if (this !is Fee.Ethereum || otherFee !is Fee.Ethereum) return this
val gasLimit = this.gasLimit
val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals)
?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP)
val increasedGasLimit = gasLimit + otherFee.gasLimit
val increasedAmount = this.amount.copy(
value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals),
)
return when (this) {
is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit)
is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit)
is Fee.Ethereum.TokenCurrency -> this
}
}
}

View file

@ -11,4 +11,6 @@ internal data class GiveApprovalUM(
@DrawableRes val walletInteractionIcon: Int?,
val isApproveButtonEnabled: Boolean,
val isApproveLoading: Boolean,
val isHoldToConfirm: Boolean = false,
val isResetApproval: Boolean = false,
)

View file

@ -27,41 +27,37 @@ 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.R
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.approval.impl.model.GiveApprovalUM
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>,
uiState: GiveApprovalUM,
amountFooter: TextReference,
feeFooter: TextReference,
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(),
text = amountFooter.resolveReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
@ -72,35 +68,39 @@ internal fun GiveApprovalContent(
ApprovalInfo(
currency = currency,
approveType = approveType,
approveItems = approveItems,
approveType = uiState.approveType,
approveItems = uiState.approveItems,
onChangeApproveType = onChangeApproveType,
onOpenLearnMoreAboutApproveClick = onOpenLearnMoreAboutApproveClick,
feeSelectorBlockComponent = feeSelectorBlockComponent,
feeFooter = feeFooter,
isResetApproval = uiState.isResetApproval,
)
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,
)
if (uiState.isHoldToConfirm) {
HoldToConfirmButton(
text = stringResourceSafe(id = R.string.common_approve),
enabled = uiState.isApproveButtonEnabled,
isLoading = uiState.isApproveLoading,
onConfirm = onApproveClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
)
} else {
PrimaryButtonIconEnd(
text = stringResourceSafe(id = R.string.common_approve),
iconResId = uiState.walletInteractionIcon,
showProgress = uiState.isApproveLoading,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
onClick = onApproveClick,
enabled = uiState.isApproveButtonEnabled,
)
}
SpacerH16()
}
@ -111,14 +111,16 @@ internal fun GiveApprovalContent(
private fun ApprovalInfo(
currency: String,
approveType: ApproveType,
isResetApproval: Boolean,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
onOpenLearnMoreAboutApproveClick: () -> Unit,
feeSelectorBlockComponent: FeeSelectorBlockComponent,
feeFooter: TextReference,
) {
FooterContainer(
footer = annotatedReference {
append(stringResourceSafe(CommonUiR.string.swap_approve_description))
append(stringResourceSafe(R.string.swap_approve_description))
append(" ")
withLink(
link = LinkAnnotation.Clickable(
@ -127,7 +129,7 @@ private fun ApprovalInfo(
),
block = {
appendColored(
text = stringResourceSafe(CommonUiR.string.common_learn_more),
text = stringResourceSafe(R.string.common_learn_more),
color = TangemTheme.colors.text.accent,
)
},
@ -144,7 +146,11 @@ private fun ApprovalInfo(
}
SpacerH16()
FooterContainer(
footer = resourceReference(CommonUiR.string.give_permission_policy_type_footer),
footer = if (isResetApproval) {
resourceReference(R.string.update_approval_permission_fee_note)
} else {
feeFooter
},
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
) {
feeSelectorBlockComponent.Content(
@ -189,7 +195,7 @@ private fun AmountItem(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(id = CommonUiR.string.give_permission_rows_amount, currency),
text = stringResourceSafe(id = R.string.give_permission_rows_amount, currency),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
@ -202,7 +208,7 @@ private fun AmountItem(
maxLines = 1,
)
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(id = CommonUiR.drawable.ic_chevron_24)),
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
@ -262,10 +268,10 @@ private fun DropdownSelector(
Text(
text = when (item) {
ApproveType.LIMITED -> stringResourceSafe(
id = CommonUiR.string.give_permission_current_transaction,
id = R.string.give_permission_current_transaction,
)
ApproveType.UNLIMITED -> stringResourceSafe(
id = CommonUiR.string.give_permission_unlimited,
id = R.string.give_permission_unlimited,
)
},
color = TangemTheme.colors.text.primary1,
@ -275,7 +281,7 @@ private fun DropdownSelector(
SpacerWMax()
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = CommonUiR.drawable.ic_check_24),
image = ImageVector.vectorResource(id = R.drawable.ic_check_24),
),
tint = color,
contentDescription = null,
@ -302,15 +308,11 @@ private fun GiveApprovalContentPreview(
TangemThemePreview {
GiveApprovalContent(
currency = params.currency,
subtitle = params.subtitle,
approveType = params.approveType,
approveItems = params.approveItems,
amountFooter = params.amountFooter,
feeFooter = params.feeFooter,
uiState = params.uiState,
onChangeApproveType = {},
walletInteractionIcon = params.walletInteractionIcon,
isApproveEnabled = params.isApproveEnabled,
isApproveLoading = params.isApproveLoading,
onApproveClick = {},
onCancelClick = {},
onOpenLearnMoreAboutApproveClick = {},
feeSelectorBlockComponent = PreviewFeeSelectorBlockComponent(),
)
@ -319,12 +321,9 @@ private fun GiveApprovalContentPreview(
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,
val amountFooter: TextReference,
val feeFooter: TextReference,
val uiState: GiveApprovalUM,
)
private class GiveApprovalContentPreviewProvider : PreviewParameterProvider<GiveApprovalPreviewParams> {
@ -332,21 +331,40 @@ private class GiveApprovalContentPreviewProvider : PreviewParameterProvider<Give
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,
amountFooter = stringReference("Allow this app to access your USDT"),
feeFooter = stringReference("The network will charge a token approval fee"),
uiState = GiveApprovalUM(
approveType = ApproveType.LIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
walletInteractionIcon = R.drawable.ic_tangem_24,
isApproveButtonEnabled = 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,
amountFooter = stringReference("Allow this app to access your USDC"),
feeFooter = stringReference("The network will charge a token approval fee"),
uiState = GiveApprovalUM(
approveType = ApproveType.UNLIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
walletInteractionIcon = R.drawable.ic_tangem_24,
isApproveButtonEnabled = false,
isApproveLoading = true,
),
),
GiveApprovalPreviewParams(
currency = "USDC",
amountFooter = stringReference("Allow this app to access your USDC"),
feeFooter = stringReference("The network will charge a token approval fee"),
uiState = GiveApprovalUM(
approveType = ApproveType.UNLIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
walletInteractionIcon = R.drawable.ic_tangem_24,
isApproveButtonEnabled = true,
isApproveLoading = false,
isResetApproval = true,
),
),
)
}

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.core.navigation)
implementation(projects.core.utils)
/** Domain */
implementation(projects.domain.wallets)
@ -45,7 +46,6 @@ dependencies {
}
/** Other */
implementation(deps.timber)
/** DI */
implementation(deps.hilt.android)

View file

@ -22,13 +22,13 @@ import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.biometry.impl.ui.state.AskBiometryUM
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("LongParameterList")
@ -88,7 +88,7 @@ internal class AskBiometryModel @Inject constructor(
* because it will be automatically saved on UserWalletsListManager switch
*/
val selectedUserWallet = getSelectedWalletUseCase.sync().getOrNull() ?: run {
Timber.e("Unable to save user wallet")
TangemLogger.e("Unable to save user wallet")
uiMessageSender.send(
SnackbarMessage(stringReference("No selected user wallet")),
)

View file

@ -68,7 +68,6 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(deps.firebase.crashlytics)
/** DI */

View file

@ -66,7 +66,6 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(deps.firebase.crashlytics)
/** DI */

View file

@ -18,7 +18,7 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.core.ui.message.dialog.Dialogs.hotWalletCreationNotSupportedDialog
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.ScanCardProcessor
@ -40,7 +40,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
private const val HIDE_PROGRESS_DELAY = 400L
@ -211,7 +211,7 @@ internal class CreateWalletStartModel @Inject constructor(
val userWallet = coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build()
if (userWallet == null) {
Timber.e("User wallet not created")
TangemLogger.e("User wallet not created")
setLoading(false)
return
}
@ -221,7 +221,7 @@ internal class CreateWalletStartModel @Inject constructor(
delay(HIDE_PROGRESS_DELAY)
setLoading(false)
when (error) {
is SaveWalletError.DataError -> Timber.e(error.toString(), "Unable to save user wallet")
is SaveWalletError.DataError -> TangemLogger.e("Unable to save user wallet: $error")
is SaveWalletError.WalletAlreadySaved -> {
userWalletsListRepository.unlock(
userWalletId = userWallet.walletId,
@ -246,17 +246,12 @@ internal class CreateWalletStartModel @Inject constructor(
private fun handleScanError(error: TangemError) {
when (error) {
is TangemSdkError.NfcFeatureIsUnavailable -> handleNfcFeatureUnavailable()
is TangemSdkError -> Timber.e(error, "Scan error occurred")
else -> Timber.e(error, "Error happened")
is TangemSdkError -> TangemLogger.e("Scan error occurred", error)
else -> TangemLogger.e("Error happened", error)
}
}
private fun handleNfcFeatureUnavailable() {
uiMessageSender.send(
message = DialogMessage(
message = resourceReference(R.string.nfc_error_unavailable),
title = resourceReference(id = R.string.common_error),
),
)
uiMessageSender.send(Dialogs.nfcFeatureUnavailable())
}
}

View file

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.configToggles)
implementation(projects.core.navigation)
implementation(projects.core.analytics)
implementation(projects.core.utils)
implementation(projects.core.analytics.models)
implementation(projects.common.routing)
implementation(projects.common.ui)
@ -78,7 +79,6 @@ dependencies {
/* Other */
implementation(deps.kotlin.immutable.collections)
implementation(deps.reKotlin)
implementation(deps.timber)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.details.model
import android.content.res.Resources
import arrow.core.getOrElse
import com.tangem.utils.logging.TangemLogger
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -45,7 +46,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
@ -84,8 +84,8 @@ internal class DetailsModel @Inject constructor(
val isWalletConnectAvailable = runBlocking {
// danger region, this works immediately, but will be refactored later with WC
checkIsWalletConnectAvailableUseCase(params.userWalletId).getOrElse {
Timber.w("Unable to check WalletConnect availability: $it")
checkIsWalletConnectAvailableUseCase(params.userWalletId).getOrElse { throwable ->
TangemLogger.w("Unable to check WalletConnect availability: $throwable")
false
}

View file

@ -29,7 +29,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@Suppress("LongParameterList")
@ -115,7 +115,7 @@ internal class UserWalletListModel @Inject constructor(
unlockWalletUseCase(userWalletId)
.onRight { router.push(AppRoute.WalletSettings(userWalletId)) }
.onLeft { error ->
Timber.e("Failed to unlock wallet $userWalletId: $error")
TangemLogger.e("Failed to unlock wallet $userWalletId: $error")
error.handle(
onUserCancelled = {},
isFromUnlockAll = false,
@ -142,7 +142,7 @@ internal class UserWalletListModel @Inject constructor(
applyUserWalletListSortingUseCase(userWalletIds).onRight {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.WalletsReorder())
}.onLeft { error ->
Timber.e("Failed to apply wallet list sorting: $error")
TangemLogger.e("Failed to apply wallet list sorting: $error")
}
}
}

View file

@ -45,7 +45,6 @@ dependencies {
/** Other dependencies */
implementation(deps.arrow.core)
implementation(deps.timber)
/** DI */
implementation(deps.hilt.android)

View file

@ -5,7 +5,7 @@ import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import com.google.accompanist.web.AccompanistWebViewClient
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
/**
* [AccompanistWebViewClient] for Disclaimer [WebView]
@ -24,7 +24,7 @@ internal class DisclaimerWebViewClient(
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
Timber.d("onPageStarted: $url")
TangemLogger.d("onPageStarted: $url")
if (url != null) {
loadedUrls[url] = false
}
@ -33,7 +33,7 @@ internal class DisclaimerWebViewClient(
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Timber.d("onPageFinished: $url")
TangemLogger.d("onPageFinished: $url")
if (url != null && loadedUrls.containsKey(url)) {
loadedUrls[url] = true
onLoadingFinished(false)
@ -45,7 +45,7 @@ internal class DisclaimerWebViewClient(
val url = request?.url?.toString()
Timber.d("onReceivedError: $url")
TangemLogger.d("onReceivedError: $url")
if (url != null && loadedUrls.containsKey(url)) {
loadedUrls[url] = true

View file

@ -14,6 +14,7 @@ interface AddToPortfolioComponent : ComposableBottomSheetComponent {
interface Callback {
fun onDismiss()
// todo swap add new onSuccess with full data of added token
fun onSuccess(addedToken: CryptoCurrency)
}

View file

@ -10,6 +10,7 @@ import kotlinx.serialization.Serializable
interface AddToPortfolioManager {
// todo swap make updatable
val token: TokenMarketParams
val analyticsParams: AnalyticsParams?
val portfolioFetcher: PortfolioFetcher

View file

@ -57,6 +57,7 @@ dependencies {
implementation(projects.domain.yieldSupply.models)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.earn)
implementation(projects.domain.search)
/* Compose */
implementation(deps.compose.coil)
@ -75,7 +76,6 @@ dependencies {
/* Other */
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)
implementation(deps.decompose.ext.compose)
/* Core */

View file

@ -22,7 +22,6 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
@ -87,14 +86,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
innerRouter.push(
route = FeedEntryChildFactory.Child.TokenList(
params = DefaultMarketsTokenListComponent.Params(
onBackClicked = { onChildBack() },
onTokenClick = { token, currency ->
onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
preselectedSortType = sortBy ?: SortByTypeUM.Rating,
shouldAlwaysShowSearchBar = sortBy == null,
),
@ -134,11 +125,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
}
override fun onOpenEarnPage() {
innerRouter.push(
FeedEntryChildFactory.Child.Earn(
params = DefaultEarnComponent.Params(onBackClick = { onChildBack() }),
),
)
innerRouter.push(FeedEntryChildFactory.Child.Earn)
}
override fun openSearch() {
innerRouter.push(FeedEntryChildFactory.Child.Search)
}
}
@ -244,14 +235,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
)
FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList(
DefaultMarketsTokenListComponent.Params(
onBackClicked = { router.pop() },
onTokenClick = { token, currency ->
clickIntents.onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
preselectedSortType = SortByTypeUM.Rating,
shouldAlwaysShowSearchBar = false,
),

View file

@ -7,15 +7,18 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent.Params
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
@ -53,9 +56,14 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data class Earn(val params: DefaultEarnComponent.Params) : Child
data object Earn : Child
@Serializable
@Immutable
data object Search : Child
}
@Suppress("LongMethod")
fun createChild(
child: Child,
appComponentContext: AppComponentContext,
@ -75,6 +83,17 @@ internal class FeedEntryChildFactory @Inject constructor(
DefaultMarketsTokenListComponent(
appComponentContext = appComponentContext,
params = child.params,
clickIntents = DefaultMarketsTokenListComponent.ClickIntents(
onBackClicked = onBackClicked,
onSearchClicked = feedEntryClickIntents::openSearch,
onTokenClick = { token, currency ->
feedEntryClickIntents.onMarketItemClick(
token = token,
appCurrency = currency,
source = AnalyticsParam.ScreensSources.Market.value,
)
},
),
)
}
is Child.NewsDetails -> {
@ -86,7 +105,7 @@ internal class FeedEntryChildFactory @Inject constructor(
Child.NewsList -> {
DefaultNewsListComponent(
appComponentContext = appComponentContext,
params = DefaultNewsListComponent.Params(
params = Params(
onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig ->
feedEntryClickIntents.onArticleClick(
articleId = currentArticle,
@ -102,7 +121,7 @@ internal class FeedEntryChildFactory @Inject constructor(
Child.Feed -> {
DefaultFeedComponent(
appComponentContext = appComponentContext,
params = DefaultFeedComponent.FeedParams(feedClickIntents = feedEntryClickIntents),
params = FeedParams(feedClickIntents = feedEntryClickIntents),
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
promoBannersBlockComponentFactory = promoBannersBlockComponentFactory,
newPromoBannersFeatureToggles = newPromoBannersFeatureToggles,
@ -111,10 +130,19 @@ internal class FeedEntryChildFactory @Inject constructor(
is Child.Earn -> {
DefaultEarnComponent(
appComponentContext = appComponentContext,
params = child.params,
params = DefaultEarnComponent.Params(
onBackClick = onBackClicked,
onSearchClicked = feedEntryClickIntents::openSearch,
),
addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent,
)
}
Child.Search -> DefaultSearchComponent(
appComponentContext = appComponentContext,
params = DefaultSearchComponent.Params(
onBackClick = onBackClicked,
),
)
}
}
}

View file

@ -1,9 +1,19 @@
package com.tangem.features.feed.components.earn
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
@ -15,15 +25,20 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.stringResourceSafe
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.components.feed.FeedBottomSheetRoute
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.model.earn.EarnModel
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.earn.EarnContent
import kotlinx.serialization.Serializable
import dev.chrisbanes.haze.HazeProgressive
internal class DefaultEarnComponent(
appComponentContext: AppComponentContext,
@ -44,23 +59,63 @@ internal class DefaultEarnComponent(
override fun Title(bottomSheetState: State<BottomSheetState>) {
val background = LocalMainBottomSheetColor.current.value
val state by earnModel.state.collectAsStateWithLifecycle()
TangemTopAppBar(
containerColor = background,
title = stringResourceSafe(R.string.earn_title),
startButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_back_24,
onClicked = state.onBackClick,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
),
)
if (LocalRedesignEnabled.current) {
FeedSearchBar(
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
feedListSearchBar = state.feedListSearchBar,
modifier = Modifier
.drawBehind { drawRect(background) }
.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
preferPerformance = true,
easing = EaseOut,
)
},
startContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(
onClick = state.onBackClick,
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
.padding(TangemTheme.dimens2.x2),
)
},
)
} else {
TangemTopAppBar(
containerColor = background,
title = stringResourceSafe(R.string.earn_title),
startButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_back_24,
onClicked = state.onBackClick,
isEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
),
)
}
}
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by earnModel.state.collectAsStateWithLifecycle()
EarnContent(
contentPadding = contentPadding,
state = state,
modifier = modifier,
)
@ -91,8 +146,8 @@ internal class DefaultEarnComponent(
)
}
@Serializable
data class Params(
val onBackClick: () -> Unit,
val onSearchClicked: () -> Unit,
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.feed.components.feed
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@ -18,14 +19,14 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent.Params
import com.tangem.features.feed.model.feed.FeedComponentModel
import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.ui.feed.FeedList
import com.tangem.features.feed.ui.feed.FeedListHeader
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
internal class DefaultFeedComponent(
appComponentContext: AppComponentContext,
@ -64,7 +65,11 @@ internal class DefaultFeedComponent(
}
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
LifecycleStartEffect(Unit) {
feedComponentModel.isVisibleOnScreen.value = true
onStopOrDispose {
@ -78,6 +83,7 @@ internal class DefaultFeedComponent(
modifier = modifier,
state = state,
promoBannersBlockComponent = promoBannersBlockComponent,
contentPadding = contentPadding,
)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -1,10 +1,19 @@
package com.tangem.features.feed.components.market.details
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
@ -12,9 +21,16 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
@ -24,6 +40,7 @@ import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnal
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar
import dev.chrisbanes.haze.HazeProgressive
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@ -86,19 +103,73 @@ internal class DefaultMarketsTokenDetailsComponent(
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
val background = LocalMainBottomSheetColor.current.value
MarketsTokenDetailsTopBar(
onBackClick = { params.onBackClicked() },
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
shouldShowPriceSubtitle = state.shouldShowPriceSubtitle,
tokenName = state.tokenName,
tokenPrice = state.priceText,
backgroundColor = background,
onShareClick = state.onShareClick,
)
if (LocalRedesignEnabled.current) {
TangemTopBar(
modifier = Modifier.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
preferPerformance = true,
easing = EaseOut,
)
},
startContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(
onClick = { params.onBackClicked() },
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
.padding(TangemTheme.dimens2.x2),
)
},
endContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_share_new_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(
onClick = state.onShareClick,
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
.padding(TangemTheme.dimens2.x2_5),
)
},
type = TangemTopBarType.BottomSheet,
)
} else {
MarketsTokenDetailsTopBar(
onBackClick = { params.onBackClicked() },
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
shouldShowPriceSubtitle = state.shouldShowPriceSubtitle,
tokenName = state.tokenName,
tokenPrice = state.priceText,
backgroundColor = background,
onShareClick = state.onShareClick,
)
}
}
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
@ -112,6 +183,7 @@ internal class DefaultMarketsTokenDetailsComponent(
}
MarketsTokenDetailsContent(
contentPadding = contentPadding,
modifier = modifier,
backgroundColor = LocalMainBottomSheetColor.current.value,
state = state,

View file

@ -8,6 +8,7 @@ import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -28,7 +29,7 @@ internal class AvailableToAddDataConverter @Inject constructor(
availableNetworks: Set<TokenMarketInfo.Network>,
marketParams: TokenMarketParams,
): AvailableToAddData {
suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? {
suspend fun AccountStatus.CryptoPortfolio.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? {
val currencies = availableNetworks
.mapNotNull { network ->
createCryptoCurrency(
@ -64,7 +65,7 @@ internal class AvailableToAddDataConverter @Inject constructor(
val (_, balance) = entry
val wallet = balance.userWallet
val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks)
val accounts = balance.accountsBalance.accountStatuses
val accounts = balance.accountsBalance.accountStatuses.filterCryptoPortfolio()
val availableToAddAccounts: Map<AccountId, AvailableToAddAccount> = accounts
.mapNotNull { accountStatus ->
val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null
@ -103,17 +104,13 @@ internal class AvailableToAddDataConverter @Inject constructor(
userWallet: UserWallet,
network: TokenMarketInfo.Network,
marketParams: TokenMarketParams,
account: Account,
account: Account.CryptoPortfolio,
): CryptoCurrency? {
val derivationIndex = when (account) {
is Account.CryptoPortfolio -> account.derivationIndex
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
return getTokenMarketCryptoCurrency(
userWalletId = userWallet.walletId,
tokenMarketParams = marketParams,
network = network,
accountIndex = derivationIndex,
accountIndex = account.derivationIndex,
)
}
}

View file

@ -34,7 +34,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
private const val TOKEN_ACTIONS_DELAY = 500L
@ -205,7 +205,7 @@ internal class AddToPortfolioModel @Inject constructor(
finishFlow()
}
.catch { throwable ->
Timber.e(throwable)
TangemLogger.e("Error", throwable)
params.callback.onDismiss()
}
.launchIn(modelScope)

View file

@ -29,7 +29,7 @@ import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import javax.inject.Inject
@ -134,7 +134,7 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor(
finishSuccessFlow(addedToken.currency, selectedPortfolioValue.userWallet.walletId)
}
.catch { throwable ->
Timber.e(throwable)
TangemLogger.e("Error", throwable)
params.callback.onDismiss()
}
.launchIn(modelScope)

View file

@ -11,7 +11,7 @@ import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase
import com.tangem.domain.managetokens.model.CurrencyUnsupportedState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.feed.impl.R
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
class CheckCurrencyUnsupportedDelegate @Inject constructor(
@ -29,14 +29,14 @@ class CheckCurrencyUnsupportedDelegate @Inject constructor(
networkId = rawNetworkId,
isMainNetwork = isMainNetwork,
).getOrElse { throwable ->
Timber.e(
throwable,
TangemLogger.e(
"""
Failed to check currency unsupported state
|- User wallet ID: $userWalletId
|- Network ID: $rawNetworkId
|- Is main network: $isMainNetwork
""".trimIndent(),
throwable,
)
val message = SnackbarMessage(

View file

@ -18,6 +18,7 @@ import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
@ -192,15 +193,12 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor(
}.distinctUntilChanged()
private fun AccountStatusList.filterByRawID(): List<AccountWithAdded> {
fun AccountStatus.filterByRawID(): List<CryptoCurrencyStatus> = when (this) {
is AccountStatus.CryptoPortfolio -> this.tokenList.flattenCurrencies()
.filter { status ->
val currencyId = status.currency.id.rawCurrencyId ?: return@filter false
getTokenIdIfL2Network(currencyId.value) == currencyRawId.value
}
is AccountStatus.Payment -> TODO("[REDACTED_JIRA]")
}
return accountStatuses.map { accountStatus ->
fun AccountStatus.CryptoPortfolio.filterByRawID(): List<CryptoCurrencyStatus> = tokenList.flattenCurrencies()
.filter { status ->
val currencyId = status.currency.id.rawCurrencyId ?: return@filter false
getTokenIdIfL2Network(currencyId.value) == currencyRawId.value
}
return accountStatuses.filterCryptoPortfolio().map { accountStatus ->
AccountWithAdded(
accountStatus = accountStatus,
addedCurrency = accountStatus.filterByRawID(),

View file

@ -1,45 +1,110 @@
package com.tangem.features.feed.components.market.list
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.market.list.MarketsList
import com.tangem.features.feed.ui.market.list.TopBarWithSearch
import dev.chrisbanes.haze.HazeProgressive
import kotlinx.serialization.Serializable
internal class DefaultMarketsTokenListComponent(
appComponentContext: AppComponentContext,
private val params: Params,
private val clickIntents: ClickIntents,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val model: MarketsListModel = getOrCreateModel<MarketsListModel, Params>(params = params)
private val model: MarketsListModel = getOrCreateModel<MarketsListModel, ModelParams>(
params = ModelParams(
params = params,
clickIntents = clickIntents,
),
)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
val bsState by bottomSheetState
TopBarWithSearch(
onBackClick = params.onBackClicked,
onSearchClick = state.onSearchClicked,
marketsSearchBar = state.marketsSearchBar,
bottomSheetState = bsState,
)
val background = LocalMainBottomSheetColor.current.value
if (LocalRedesignEnabled.current) {
FeedSearchBar(
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
feedListSearchBar = state.feedListSearchBar,
modifier = Modifier
.drawBehind { drawRect(background) }
.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
preferPerformance = true,
easing = EaseOut,
)
},
startContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(
onClick = clickIntents.onBackClicked,
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
.padding(TangemTheme.dimens2.x2),
)
},
)
} else {
TopBarWithSearch(
onBackClick = clickIntents.onBackClicked,
onSearchClick = state.onSearchClicked,
marketsSearchBar = state.marketsSearchBar,
bottomSheetState = bsState,
)
}
}
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
@ -55,6 +120,7 @@ internal class DefaultMarketsTokenListComponent(
}
MarketsList(
contentPadding = contentPadding,
modifier = modifier,
state = state,
)
@ -62,9 +128,18 @@ internal class DefaultMarketsTokenListComponent(
@Serializable
data class Params(
val onBackClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
val preselectedSortType: SortByTypeUM,
val shouldAlwaysShowSearchBar: Boolean,
)
data class ClickIntents(
val onBackClicked: () -> Unit,
val onSearchClicked: () -> Unit,
val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit),
)
data class ModelParams(
val params: Params,
val clickIntents: ClickIntents,
)
}

View file

@ -1,6 +1,8 @@
package com.tangem.features.feed.components.news.details
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@ -18,11 +20,11 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@ -32,6 +34,7 @@ import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.ui.news.details.NewsDetailsContent
import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM
import dev.chrisbanes.haze.HazeProgressive
import kotlinx.serialization.Serializable
internal class DefaultNewsDetailsComponent(
@ -47,7 +50,14 @@ internal class DefaultNewsDetailsComponent(
val state by newsDetailsModel.state.collectAsStateWithLifecycle()
if (LocalRedesignEnabled.current) {
TangemTopBar(
title = resourceReference(R.string.common_news),
modifier = Modifier.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
preferPerformance = true,
easing = EaseOut,
)
},
type = TangemTopBarType.BottomSheet,
startContent = {
Icon(
@ -90,7 +100,6 @@ internal class DefaultNewsDetailsComponent(
} else {
TangemTopAppBar(
containerColor = background,
title = null,
startButton = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_back_24,
onClicked = state.onBackClick,
@ -107,11 +116,16 @@ internal class DefaultNewsDetailsComponent(
}
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
val state by newsDetailsModel.state.collectAsStateWithLifecycle()
NewsDetailsContent(
state = state,
modifier = modifier,
contentPadding = contentPadding,
)
}

View file

@ -1,6 +1,8 @@
package com.tangem.features.feed.components.news.list
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@ -18,6 +20,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
@ -30,6 +33,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.model.news.list.NewsListModel
import com.tangem.features.feed.ui.news.list.NewsListContent
import dev.chrisbanes.haze.HazeProgressive
import kotlinx.serialization.Serializable
internal class DefaultNewsListComponent(
@ -45,6 +49,14 @@ internal class DefaultNewsListComponent(
val state by newsListModel.state.collectAsStateWithLifecycle()
if (LocalRedesignEnabled.current) {
TangemTopBar(
modifier = Modifier.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
preferPerformance = true,
easing = EaseOut,
)
},
title = resourceReference(R.string.common_news),
type = TangemTopBarType.BottomSheet,
startContent = {
@ -80,11 +92,16 @@ internal class DefaultNewsListComponent(
}
@Composable
override fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier) {
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
val state by newsListModel.state.collectAsStateWithLifecycle()
NewsListContent(
state = state,
modifier = modifier,
contentPadding = contentPadding,
)
}

View file

@ -0,0 +1,90 @@
package com.tangem.features.feed.components.search
import androidx.compose.animation.core.EaseOut
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.field.search.TangemFieldShape
import com.tangem.core.ui.ds.field.search.TangemSearchField
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.features.feed.model.search.SearchModel
import com.tangem.features.feed.ui.search.SearchContent
import com.tangem.features.feed.ui.search.state.SearchCallbacks
import dev.chrisbanes.haze.HazeProgressive
internal class DefaultSearchComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
private val model = getOrCreateModel<SearchModel, Params>(params = params)
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(bottomSheetState.value) {
if (bottomSheetState.value == BottomSheetState.EXPANDED) {
focusRequester.requestFocus()
}
}
TangemTopBar(
modifier = Modifier.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .55f,
endIntensity = 0f,
preferPerformance = true,
easing = EaseOut,
)
},
type = TangemTopBarType.BottomSheet,
reserveSlotSpace = false,
content = {
TangemSearchField(
state = state.searchBar,
shape = TangemFieldShape.Circle,
focusRequester = focusRequester,
modifier = Modifier.weight(1f),
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
},
)
}
@Composable
override fun Content(
bottomSheetState: State<BottomSheetState>,
contentPadding: PaddingValues,
modifier: Modifier,
) {
val state by model.state.collectAsStateWithLifecycle()
val searchCallbacks = remember {
SearchCallbacks(
onLoadMore = model::loadMore,
onClearHintsClick = model::clearSearchHistory,
onTextHintClick = model::onTextHintClick,
onResultMarketTokenClick = model::onResultMarketTokenClick,
)
}
SearchContent(
modifier = modifier,
content = state.content,
searchCallbacks = searchCallbacks,
contentPadding = contentPadding,
)
}
data class Params(
val onBackClick: () -> Unit,
)
}

View file

@ -16,7 +16,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@ -44,7 +44,7 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc
tokenId = rawTokenId,
tokenSymbol = "", // used for analytics
).getOrElse {
Timber.e("Failed to get market token info")
TangemLogger.e("Failed to get market token info")
return@launch
}

View file

@ -9,7 +9,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
internal class DefaultNewsDetailsDeepLinkHandler @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@ -25,7 +25,7 @@ internal class DefaultNewsDetailsDeepLinkHandler @AssistedInject constructor(
scope.launch {
val articleId = extractArticleIdFromUri(deeplinkUri)
if (articleId == null) {
Timber.e(
TangemLogger.e(
"""
Failed to extract article ID from deep link
|- Received URI: $deeplinkUri

View file

@ -11,6 +11,7 @@ import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
import com.tangem.features.feed.model.market.list.MarketsListModel
import com.tangem.features.feed.model.news.details.NewsDetailsModel
import com.tangem.features.feed.model.news.list.NewsListModel
import com.tangem.features.feed.model.search.SearchModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -65,4 +66,9 @@ internal interface ModelModule {
@IntoMap
@ClassKey(EarnTypeFilterModel::class)
fun provideEarnTypeFilterModel(model: EarnTypeFilterModel): Model
@Binds
@IntoMap
@ClassKey(SearchModel::class)
fun provideSearchModel(model: SearchModel): Model
}

View file

@ -35,10 +35,11 @@ internal class EarnTokenWithCurrencyToListItemUMConverter(
formatArgs = wrappedList(convertPercent(value.earnToken.apy)),
)
},
earnType = when (value.earnToken.type) {
earnTypeTitle = when (value.earnToken.type) {
EarnType.STAKING -> TextReference.Res(R.string.common_staking)
EarnType.YIELD -> TextReference.Res(R.string.common_yield_mode)
},
earnType = value.earnToken.type,
onItemClick = { onItemClick(value) },
)
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
@ -110,7 +111,12 @@ internal class MarketsTokenItemConverter(
return MarketsListItemUM.Price(
text = priceText,
annotated = tokenQuotesShort.currentPrice.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code,
appCurrencySymbol = appCurrency.symbol,
),
changeType = changeType,
fiatPrice = tokenQuotesShort.currentPrice,
)
}

View file

@ -325,6 +325,7 @@ internal class EarnModel @Inject constructor(
onNetworkFilterClick = ::onNetworkFilterClick,
onTypeFilterClick = ::onTypeFilterClick,
onScroll = ::onMostlyUsedScrolled,
onSearchBarClicked = params.onSearchClicked,
),
)
}

View file

@ -1,8 +1,10 @@
package com.tangem.features.feed.model.earn.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.model.earn.state.transformers.EarnUMTransformer
import com.tangem.features.feed.ui.earn.state.*
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -34,6 +36,10 @@ internal class EarnStateController @Inject constructor() {
onNetworkFilterClick = {},
onTypeFilterClick = {},
onSliderScroll = {},
feedListSearchBar = FeedListSearchBar(
placeholderText = TextReference.EMPTY,
onBarClick = {},
),
)
}
}

View file

@ -1,12 +1,16 @@
package com.tangem.features.feed.model.earn.state.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.earn.state.EarnUM
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
internal class UpdateEarnUMInitialStateTransformer(
private val onBackClick: () -> Unit,
private val onNetworkFilterClick: () -> Unit,
private val onTypeFilterClick: () -> Unit,
private val onScroll: () -> Unit,
private val onSearchBarClicked: () -> Unit,
) : EarnUMTransformer {
override fun transform(prevState: EarnUM): EarnUM {
@ -15,6 +19,10 @@ internal class UpdateEarnUMInitialStateTransformer(
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
onSliderScroll = onScroll,
feedListSearchBar = FeedListSearchBar(
onBarClick = onSearchBarClicked,
placeholderText = resourceReference(id = R.string.markets_search_title_placeholder),
),
)
}
}

View file

@ -13,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
@ -66,6 +67,7 @@ internal class FeedComponentModel @Inject constructor(
private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase,
private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase,
private val appRouter: AppRouter,
private val designFeatureToggles: DesignFeatureToggles,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
paramsContainer: ParamsContainer,
@ -232,10 +234,20 @@ internal class FeedComponentModel @Inject constructor(
return FeedListUM(
currentDate = getCurrentDate(),
feedListSearchBar = FeedListSearchBar(
placeholderText = resourceReference(R.string.markets_search_header_title),
placeholderText = resourceReference(
id = if (designFeatureToggles.isRedesignEnabled) {
R.string.markets_search_title_placeholder
} else {
R.string.markets_search_header_title
},
),
onBarClick = {
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
params.feedClickIntents.onMarketOpenClick(null)
if (designFeatureToggles.isRedesignEnabled) {
params.feedClickIntents.openSearch()
} else {
params.feedClickIntents.onMarketOpenClick(null)
}
},
),
feedListCallbacks = FeedListCallbacks(

View file

@ -29,4 +29,6 @@ internal interface FeedModelClickIntents {
fun onOpenAllNews()
fun onOpenEarnPage()
fun openSearch()
}

View file

@ -188,6 +188,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
marketChartLook.copy(
type = percentChangeType.toChartType(),
isMinMaxLook = designFeatureToggles.isRedesignEnabled,
xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24),
yAxisFormatter = { value ->
value.format {
@ -229,6 +230,9 @@ internal class MarketsTokenDetailsModel @Inject constructor(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
},
priceAnnotated = params.token.tokenQuotes.currentPrice.toMarketsTokenDetailsPriceAnnotated(
currentAppCurrency.value,
),
dateTimeText = resourceReference(R.string.common_today),
priceChangePercentText = params.token.tokenQuotes.h24Percent?.format { percent() },
priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(),
@ -504,6 +508,9 @@ internal class MarketsTokenDetailsModel @Inject constructor(
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
},
priceAnnotated = newInfo.quotes.currentPrice.toMarketsTokenDetailsPriceAnnotated(
currentAppCurrency.value,
),
priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval(
interval = marketsTokenDetailsUM.selectedInterval,
),
@ -603,7 +610,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
)
} ?: getDefaultDateTimeString(currentState.selectedInterval)
val priceText = (price ?: currentQuotes.value.currentPrice).format {
val amountForPrice = price ?: currentQuotes.value.currentPrice
val priceText = amountForPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
@ -624,6 +632,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
isMarkerSet = markerTimestamp != null,
dateTimeText = dateTimeText,
priceText = priceText,
priceAnnotated = amountForPrice.toMarketsTokenDetailsPriceAnnotated(currentAppCurrency.value),
priceChangePercentText = percentText,
priceChangeType = percent.percentChangeType(),
)

View file

@ -1,9 +1,7 @@
package com.tangem.features.feed.model.market.details.converter
import androidx.compose.runtime.Stable
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.extensions.*
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
@ -37,7 +35,7 @@ internal class MetricsConverter(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = marketCap.formatAmount(),
value = marketCap.formatAmount() ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
@ -65,7 +63,7 @@ internal class MetricsConverter(
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = volume24h.formatAmount(),
value = volume24h.formatAmount() ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
@ -79,7 +77,7 @@ internal class MetricsConverter(
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = fullyDilutedValuation.formatAmount(),
value = fullyDilutedValuation.formatAmount() ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
@ -95,7 +93,7 @@ internal class MetricsConverter(
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = circulatingSupply.formatAmount(crypto = true),
value = circulatingSupply.formatAmount(crypto = true) ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
@ -109,7 +107,7 @@ internal class MetricsConverter(
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_max_supply),
value = maxSupply.formatMaxSupply(),
value = maxSupply.formatMaxSupply() ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
@ -135,7 +133,7 @@ internal class MetricsConverter(
val liquidity = getLiquidity(value.volume24h, value.marketCap)
persistentListOf(
InfoPointUMV2.MarketCap(
capitalizationValue = stringReference(marketCap.formatAmount()),
capitalizationValue = marketCap.formatAmount()?.let(::stringReference),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
@ -150,7 +148,7 @@ internal class MetricsConverter(
},
),
InfoPointUMV2.TradingVolume(
tradingValue = stringReference(volume24h.formatAmount()),
tradingValue = volume24h.formatAmount()?.let(::stringReference),
liquidity = liquidity,
trendingVolumeLiquidityType = getTrendingVolumeLiquidityType(liquidity),
onInfoClick = {
@ -165,7 +163,9 @@ internal class MetricsConverter(
},
),
InfoPointUMV2.MarketPosition(
position = marketRating?.toString() ?: StringsSigns.DASH_SIGN,
position = marketRating?.let {
stringReference(it.toString())
},
rangeValue = getMarketRatingRangeValue(marketRating),
marketRatingType = getMarketRatingType(marketRating),
onInfoClick = {
@ -176,12 +176,16 @@ internal class MetricsConverter(
),
)
},
marketRatingChange24H = getMarketRatingChange(marketRatingChange24h),
),
InfoPointUMV2.FullyDilutedValuation(
value = resourceReference(
R.string.markets_token_details_valuation_value_in_total,
wrappedList(fullyDilutedValuation.formatAmount()),
),
value = fullyDilutedValuation?.formatAmount()?.let { formatted ->
resourceReference(
id = R.string.markets_token_details_valuation_value_in_total,
formatArgs = wrappedList(formatted),
)
},
fullyDilutedValuationChange24 = fullyDilutedValuationChange24?.convertChange(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
@ -196,20 +200,29 @@ internal class MetricsConverter(
},
),
InfoPointUMV2.CirculatingSupply(
currentValue = stringReference(circulatingSupply.formatAmount(crypto = true)),
maxValue = maxSupply?.let { supply ->
if (supply > BigDecimal.ZERO) {
stringReference(supply.formatMaxSupply())
} else {
null
currentValue = circulatingSupply?.formatAmount(true)?.let(::stringReference),
maxValue = when (maxSupply) {
null -> null
BigDecimal.ZERO -> {
resourceReference(
R.string
.markets_token_details_metrics_no_limited,
)
}
else -> maxSupply.formatAmount(true)?.let(::stringReference)
},
fillValue = getCirculatingSupplyFillValue(circulatingSupply, maxSupply),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_max_supply_full),
body = resourceReference(R.string.markets_token_details_total_supply_description),
title = resourceReference(
R.string.markets_token_details_max_supply_and_circulation_full,
),
body = combinedReference(
resourceReference(R.string.markets_token_details_circulating_supply_description),
stringReference("\n\n"),
resourceReference(R.string.markets_token_details_total_supply_description),
),
),
)
},
@ -234,7 +247,7 @@ internal class MetricsConverter(
)
}
private fun BigDecimal?.formatMaxSupply(): String {
private fun BigDecimal?.formatMaxSupply(): String? {
when (this) {
null -> return StringsSigns.DASH_SIGN
BigDecimal.ZERO -> return StringsSigns.INFINITY_SIGN
@ -243,8 +256,8 @@ internal class MetricsConverter(
return this.formatAmount(crypto = true)
}
private fun BigDecimal?.formatAmount(crypto: Boolean = false): String {
if (this == null) return StringsSigns.DASH_SIGN
private fun BigDecimal?.formatAmount(crypto: Boolean = false): String? {
if (this == null) return null
return if (crypto) {
format {
@ -265,10 +278,20 @@ internal class MetricsConverter(
}
}
@Suppress("MagicNumber")
private fun getLiquidity(volume24h: BigDecimal?, marketCap: BigDecimal?): Float {
if (volume24h == null || marketCap == null || marketCap == BigDecimal.ZERO) return 0f
private fun BigDecimal?.convertChange(): TextReference? {
if (this == null) return null
val amount = this.abs().formatAmount()
val value = when {
this > BigDecimal.ZERO -> StringsSigns.PLUS + amount
this < BigDecimal.ZERO -> StringsSigns.MINUS + amount
else -> amount
}
return value?.let(::stringReference)
}
@Suppress("MagicNumber")
private fun getLiquidity(volume24h: BigDecimal?, marketCap: BigDecimal?): Float? {
if (volume24h == null || marketCap == null || marketCap == BigDecimal.ZERO) return null
val ratio = volume24h
.divide(marketCap, 6, RoundingMode.HALF_UP)
.toFloat()
@ -277,8 +300,9 @@ internal class MetricsConverter(
}
@Suppress("MagicNumber")
private fun getTrendingVolumeLiquidityType(liquidity: Float): TrendingVolumeLiquidityType {
private fun getTrendingVolumeLiquidityType(liquidity: Float?): TrendingVolumeLiquidityType {
return when {
liquidity == null -> TrendingVolumeLiquidityType.UNKNOWN
liquidity >= 0.5f -> TrendingVolumeLiquidityType.HIGH
liquidity in 0.2f..<0.5f -> TrendingVolumeLiquidityType.MEDIUM
else -> TrendingVolumeLiquidityType.LOW
@ -296,8 +320,8 @@ internal class MetricsConverter(
}
@Suppress("MagicNumber")
private fun getMarketRatingRangeValue(marketRating: Int?): Float {
if (marketRating == null) return 0f
private fun getMarketRatingRangeValue(marketRating: Int?): Float? {
if (marketRating == null) return null
return when {
marketRating <= 20 -> {
@ -352,4 +376,12 @@ internal class MetricsConverter(
return ratio.coerceIn(0f, 1f)
}
private fun getMarketRatingChange(change: Int?): MarketRatingChange24H {
return when {
change == null || change == 0 -> MarketRatingChange24H.NoChanges
change < 0 -> MarketRatingChange24H.Down(-change)
else -> MarketRatingChange24H.Up(change)
}
}
}

View file

@ -1,10 +1,12 @@
package com.tangem.features.feed.model.market.details.formatter
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getFiatPriceAmountWithScale
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenQuotes
import java.math.BigDecimal
@ -73,4 +75,14 @@ internal fun PriceChangeType.toChartType(): MarketChartLook.Type {
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral
}
}
internal fun BigDecimal.toMarketsTokenDetailsPriceAnnotated(appCurrency: AppCurrency): TextReference {
return formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.tertiary) },
).price()
}
}

View file

@ -66,6 +66,7 @@ internal class QuotesStateUpdater(
fiatCurrencyCode = currentAppCurrency().code,
).price()
},
priceAnnotated = newQuotes.currentPrice.toMarketsTokenDetailsPriceAnnotated(currentAppCurrency()),
priceChangePercentText = newQuotes.getFormattedPercentByInterval(
interval = stateToUpdate.selectedInterval,
),

View file

@ -45,7 +45,7 @@ internal class MarketsListModel @Inject constructor(
private val updateQuotesJob = JobHolder()
private val params = paramsContainer.require<DefaultMarketsTokenListComponent.Params>()
private val modelParams = paramsContainer.require<DefaultMarketsTokenListComponent.ModelParams>()
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
@ -65,10 +65,11 @@ internal class MarketsListModel @Inject constructor(
onRetryButtonClicked = { activeListManager.reload() },
onTokenClick = { onTokenUIClicked(it) },
onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) },
shouldAlwaysShowSearchBar = Provider { params.shouldAlwaysShowSearchBar },
preselectedSortType = Provider { params.preselectedSortType },
onBackClick = params.onBackClicked,
shouldAlwaysShowSearchBar = Provider { modelParams.params.shouldAlwaysShowSearchBar },
preselectedSortType = Provider { modelParams.params.preselectedSortType },
onBackClick = modelParams.clickIntents.onBackClicked,
analyticsEventHandler = analyticsEventHandler,
onSearchBarClick = modelParams.clickIntents.onSearchClicked,
)
}
@ -261,7 +262,7 @@ internal class MarketsListModel @Inject constructor(
private fun onTokenUIClicked(token: MarketsListItemUM) {
modelScope.launch {
activeListManager.getTokenById(token.id)?.let { found ->
params.onTokenClick(found.toSerializableParam(), currentAppCurrency.value)
modelParams.clickIntents.onTokenClick(found.toSerializableParam(), currentAppCurrency.value)
}
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
import kotlinx.collections.immutable.ImmutableList
internal data class MarketsListUM(
@ -20,6 +21,8 @@ internal data class MarketsListUM(
val onIntervalClick: (TrendInterval) -> Unit,
val onSortByButtonClick: () -> Unit,
val onSearchClicked: () -> Unit,
val feedListSearchBar: FeedListSearchBar,
val sortByMenuUM: SortByMenuUM,
) {
val isInSearchMode
get() = marketsSearchBar.searchBarUM.query.isNotEmpty()

View file

@ -0,0 +1,6 @@
package com.tangem.features.feed.model.market.list.state
internal data class SortByMenuUM(
val selectedOption: SortByTypeUM,
val onOptionClicked: (SortByTypeUM) -> Unit,
)

View file

@ -75,6 +75,11 @@ internal class MarketsListBatchFlowManager(
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = stateManager.state.map { it.uiBatches }
val rawItems: Flow<List<TokenMarket>>
get() = batchFlow.state
.map { state -> state.data.flatMap { batch -> batch.data } }
.distinctUntilChanged()
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
get() = uiBatches
.map { batches ->

View file

@ -12,6 +12,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent
import com.tangem.features.feed.model.market.list.state.*
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -33,6 +34,7 @@ internal class MarketsListUMStateManager(
private val onShowTokensUnder100kClicked: () -> Unit,
private val onBackClick: () -> Unit,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onSearchBarClick: () -> Unit,
) {
val state = MutableStateFlow(state())
@ -64,6 +66,9 @@ internal class MarketsListUMStateManager(
selectedOption = value,
),
),
sortByMenuUM = marketsListUM.sortByMenuUM.copy(
selectedOption = value,
),
list = if (marketsListUM.list is ListUM.Content && marketsListUM.selectedSortBy != value) {
marketsListUM.list.copy(
triggerScrollReset = triggeredEvent(Unit) { consumeTriggerResetScrollEvent() },
@ -232,16 +237,24 @@ internal class MarketsListUMStateManager(
onDismissRequest = { isSortByBottomSheetShown = false },
content = SortByBottomSheetContentUM(
selectedOption = preselectedSortType(),
onOptionClicked = ::onBottomSheetOptionClicked,
onOptionClicked = ::onBottomSheetOrMenuOptionClicked,
),
),
onSearchClicked = {
analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked())
changeSearchBarIsActive(true)
},
feedListSearchBar = FeedListSearchBar(
onBarClick = onSearchBarClick,
placeholderText = resourceReference(id = R.string.markets_search_title_placeholder),
),
sortByMenuUM = SortByMenuUM(
selectedOption = preselectedSortType(),
onOptionClicked = ::onBottomSheetOrMenuOptionClicked,
),
)
private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) {
private fun onBottomSheetOrMenuOptionClicked(sortByTypeUM: SortByTypeUM) {
state.update { marketsListUM ->
marketsListUM.copy(
selectedSortBy = sortByTypeUM,
@ -251,6 +264,7 @@ internal class MarketsListUMStateManager(
selectedOption = sortByTypeUM,
),
),
sortByMenuUM = marketsListUM.sortByMenuUM.copy(selectedOption = sortByTypeUM),
)
}
}

View file

@ -6,10 +6,10 @@ import com.tangem.domain.markets.TokenMarketUpdateRequest
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchUpdateResult
import com.tangem.pagination.PaginationStatus
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
internal fun logStatus(tag: String, status: PaginationStatus<List<TokenMarket>>) {
Timber.tag(tag).d(
TangemLogger.withTag(tag).d(
"""
Status
$status
@ -19,19 +19,19 @@ internal fun logStatus(tag: String, status: PaginationStatus<List<TokenMarket>>)
internal fun logAction(tag: String, action: BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>) {
when (action) {
is BatchAction.Reload -> Timber.tag(tag).d(
is BatchAction.Reload -> TangemLogger.withTag(tag).d(
"""
Reload = ${action.requestParams}
""".trimIndent(),
)
is BatchAction.UpdateBatches -> Timber.tag(tag).d(
is BatchAction.UpdateBatches -> TangemLogger.withTag(tag).d(
"""
To update:
keys: ${action.keys.toList()}
updateType: ${action.updateRequest.javaClass.simpleName}
""".trimIndent(),
)
else -> Timber.tag(tag).d(
else -> TangemLogger.withTag(tag).d(
"""
$action
""".trimIndent(),
@ -48,7 +48,7 @@ internal fun logUpdateResults(
is BatchUpdateResult.Error -> s.throwable.toString()
}
Timber.tag(tag).d(
TangemLogger.withTag(tag).d(
"""
updateResults
request: ${updateResult.first}

View file

@ -35,7 +35,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import java.util.Locale
import javax.inject.Inject
@ -134,7 +134,7 @@ internal class NewsDetailsModel @Inject constructor(
modelScope.launch {
toggleArticleLikedUseCase
.toggleLiked(articleId)
.onLeft { Timber.e(it) }
.onLeft { TangemLogger.e("Error", it) }
analyticsEventHandler.send(NewsDetailsAnalyticsEvent.NewsLikeClicked(articleId))
}
}
@ -231,7 +231,7 @@ internal class NewsDetailsModel @Inject constructor(
}
// global request executing error
newsId < 0 -> {
Timber.e(error)
TangemLogger.e("Error", error)
}
else -> {
val (code, message) = when (error) {

View file

@ -6,6 +6,7 @@ import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
@ -68,7 +69,12 @@ internal class RelatedTokenConverter(private val appCurrency: AppCurrency) :
return MarketsListItemUM.Price(
text = priceText,
annotated = tokenInfo.quotes.currentPrice.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code,
appCurrencySymbol = appCurrency.symbol,
),
changeType = null,
fiatPrice = tokenInfo.quotes.currentPrice,
)
}

View file

@ -0,0 +1,352 @@
package com.tangem.features.feed.model.search
import arrow.core.getOrElse
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.GetTokenPriceChartUseCase
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase
import com.tangem.domain.search.usecase.GetSearchResultsUseCase
import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase
import com.tangem.domain.search.usecase.SaveSearchQueryUseCase
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager
import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecentSearchTokenConverter
import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency
import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter
import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency
import com.tangem.features.feed.model.search.state.SearchStateController
import com.tangem.features.feed.model.search.state.transformers.*
import com.tangem.features.feed.ui.search.state.*
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L
private const val MARKET_SEARCH_DEBOUNCE_MS = 500L
@Suppress("LongParameterList")
@ModelScoped
internal class SearchModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getSearchResultsUseCase: GetSearchResultsUseCase,
private val saveSearchQueryUseCase: SaveSearchQueryUseCase,
private val saveRecentSearchTokenUseCase: SaveRecentSearchTokenUseCase,
private val clearSearchHistoryUseCase: ClearSearchHistoryUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val stateController: SearchStateController,
) : Model() {
private val params = paramsContainer.require<DefaultSearchComponent.Params>()
private val updateQuotesJob = JobHolder()
private val searchResultsJob = JobHolder()
private val marketSearchDebounceJob = JobHolder()
private var shouldShowAllTokensIncludingUnder100k = false
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val marketsListItemToRecentSearchTokenConverter by lazy {
MarketsListItemUMToRecentSearchTokenConverter()
}
private val recentSearchTokenToMarketsListItemConverter by lazy {
RecentSearchTokenToMarketsListItemUMConverter()
}
private val priceAndTimePointValuesConverter by lazy {
PriceAndTimePointValuesConverter(shouldFormatAxis = false)
}
private val searchMarketsListManager by lazy {
MarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
currentAppCurrency = Provider { currentAppCurrency.value },
currentTrendInterval = Provider { MarketsListUM.TrendInterval.H24 },
currentSortByType = Provider { SortByTypeUM.Rating },
currentSearchText = Provider { stateController.value.searchBar.query },
modelScope = modelScope,
dispatchers = dispatchers,
)
}
val state: StateFlow<SearchUM> get() = stateController.uiState
init {
initCallbacks()
subscribeToQueryChanges()
subscribeToMarketUiItems()
subscribeToQuotesPolling()
subscribeToAppCurrencyChanges()
loadHistory()
}
fun loadMore() {
val content = stateController.value.content
if (content is SearchContentUM.Results) {
val market = content.marketTokens
if (market is MarketSearchResultUM.Content) {
searchMarketsListManager.loadMore()
}
}
}
fun clearSearchHistory() {
modelScope.launch(dispatchers.default) {
clearSearchHistoryUseCase()
}
}
fun onTextHintClick(text: String) {
stateController.update(UpdateSearchBarQueryTransformer(text))
}
fun onResultMarketTokenClick(item: MarketsListItemUM) {
modelScope.launch(dispatchers.default) {
val appCurrency = currentAppCurrency.value
val input = MarketsListItemUMWithAppCurrency(
item = item,
appCurrencyCode = appCurrency.code,
appCurrencySymbol = appCurrency.symbol,
)
saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input))
saveSearchQueryUseCase(stateController.value.searchBar.query)
}
}
private fun initCallbacks() {
stateController.update(object : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(
onQueryChange = ::onQueryChange,
onActiveChange = ::onActiveChange,
onClearClick = ::onClearClick,
),
)
}
})
}
private fun onQueryChange(query: String) {
stateController.update(UpdateSearchBarQueryTransformer(query))
}
private fun onActiveChange(isActive: Boolean) {
if (!isActive) params.onBackClick()
}
private fun onClearClick() {
stateController.update(UpdateSearchBarQueryTransformer(""))
}
private fun subscribeToQueryChanges() {
stateController.uiState
.map { it.searchBar.query.trim() }
.distinctUntilChanged()
.onEach { query ->
shouldShowAllTokensIncludingUnder100k = false
if (query.isEmpty()) {
marketSearchDebounceJob.cancel()
searchMarketsListManager.clearStateAndStopAllActions()
updateQuotesJob.cancel()
loadHistory()
} else {
stateController.update(SetSearchResultsLoadingTransformer())
subscribeToSearchResults(query)
modelScope.launch(dispatchers.default) {
delay(MARKET_SEARCH_DEBOUNCE_MS)
val latestQuery = stateController.value.searchBar.query.trim()
if (latestQuery.isNotEmpty()) {
searchMarketsListManager.reload(searchText = latestQuery)
}
}.saveIn(marketSearchDebounceJob)
}
}
.launchIn(modelScope)
}
private fun subscribeToSearchResults(query: String) {
modelScope.launch {
getSearchResultsUseCase(query = query).collectLatest { searchResult ->
val userAssets = searchResult.userAssets.map { entry ->
UserAssetItemUM(
id = "${entry.userWalletId.stringValue}_${entry.accountId.value}" +
"_${entry.currencyStatus.currency.id.value}",
tokenIconUrl = entry.currencyStatus.currency.iconUrl,
tokenName = entry.currencyStatus.currency.name,
tokenSymbol = entry.currencyStatus.currency.symbol,
accountName = entry.accountName.toDisplayString(),
onClick = {
// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task.
},
)
}.toImmutableList()
stateController.update(UpdateUserAssetsTransformer(userAssets))
}
}.saveIn(searchResultsJob)
}
private fun subscribeToQuotesPolling() {
searchMarketsListManager.onLastBatchLoadedSuccess.onEach { batchKey ->
searchMarketsListManager.loadCharts(setOf(batchKey), MarketsListUM.TrendInterval.H24)
modelScope.loadQuotesWithTimer(UPDATE_QUOTES_TIMER_MILLIS)
}.launchIn(modelScope)
}
private fun subscribeToAppCurrencyChanges() {
currentAppCurrency.drop(1).onEach {
val query = stateController.value.searchBar.query
if (query.isNotEmpty()) {
searchMarketsListManager.reload()
}
}.launchIn(modelScope)
}
private fun subscribeToMarketUiItems() {
combine(
flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(),
flow2 = searchMarketsListManager.uiItems,
flow3 = searchMarketsListManager.isSearchNotFoundState,
flow4 = searchMarketsListManager.isInInitialLoadingErrorState,
) { query, uiItems, isSearchNotFound, isInErrorState ->
if (query.isEmpty()) return@combine null
val marketResult = when {
isSearchNotFound -> MarketSearchResultUM.NotFound
isInErrorState -> MarketSearchResultUM.NotFound
uiItems.isEmpty() -> MarketSearchResultUM.Empty
else -> buildMarketContent(uiItems)
}
SearchMarketBatchUiSnapshot(
marketResult = marketResult,
isSearchNotFound = isSearchNotFound,
isInErrorState = isInErrorState,
)
}
.filterNotNull()
.onEach { snapshot ->
stateController.update(ApplySearchMarketBatchTransformer(snapshot))
}
.launchIn(modelScope)
}
private fun buildMarketContent(allItems: ImmutableList<MarketsListItemUM>): MarketSearchResultUM.Content {
if (shouldShowAllTokensIncludingUnder100k) {
return MarketSearchResultUM.Content(items = allItems)
}
val filtered = allItems.filter { !it.isUnder100kMarketCap }.toImmutableList()
val hasUnder100k = filtered.size != allItems.size
return if (hasUnder100k) {
MarketSearchResultUM.Content(
items = filtered,
shouldShowUnder100kNotification = true,
onShowUnder100kClick = {
shouldShowAllTokensIncludingUnder100k = true
stateController.update(
UpdateMarketItemsTransformer(
MarketSearchResultUM.Content(items = allItems),
),
)
},
)
} else {
MarketSearchResultUM.Content(items = allItems)
}
}
private fun loadHistory() {
modelScope.launch {
getSearchResultsUseCase(query = "").collectLatest { searchResult ->
val textHints = searchResult.textHints.map { hint ->
TextHintItemUM(text = hint.text)
}.toImmutableList()
val appCurrency = currentAppCurrency.value
val recentTokens = searchResult.recentTokens.map { token ->
recentSearchTokenToMarketsListItemConverter.convert(
RecentSearchTokenWithAppCurrency(token = token, appCurrency = appCurrency),
)
}.toImmutableList()
stateController.update(UpdateHistoryTransformer(textHints, recentTokens))
loadRecentTokenCharts(recentTokens, appCurrency)
}
}.saveIn(searchResultsJob)
}
private suspend fun loadRecentTokenCharts(tokens: ImmutableList<MarketsListItemUM>, appCurrency: AppCurrency) {
coroutineScope {
tokens.map { token ->
async(dispatchers.io) {
val chart = getTokenPriceChartUseCase(
appCurrency = appCurrency,
interval = PriceChangeInterval.H24,
tokenId = token.id,
tokenSymbol = token.currencySymbol,
preview = true,
).getOrElse { return@async }
val chartData = priceAndTimePointValuesConverter.convert(
MarketChartData.Data(
y = chart.priceY.toImmutableList(),
x = chart.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted(),
)
stateController.update(UpdateRecentTokenChartTransformer(token.id, chartData))
}
}.awaitAll()
}
}
private fun AccountName.toDisplayString(): String {
return when (this) {
is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize
is AccountName.Custom -> value
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {
delay(timeMillis)
searchMarketsListManager.updateQuotes()
}
}.saveIn(updateQuotesJob)
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.search.model.RecentSearchToken
import com.tangem.domain.search.model.TokenPriceChangeDirection
import com.tangem.utils.converter.Converter
internal class MarketsListItemUMToRecentSearchTokenConverter :
Converter<MarketsListItemUMWithAppCurrency, RecentSearchToken> {
override fun convert(value: MarketsListItemUMWithAppCurrency): RecentSearchToken {
val item = value.item
val direction = when (item.trendType) {
PriceChangeType.UP -> TokenPriceChangeDirection.UP
PriceChangeType.DOWN -> TokenPriceChangeDirection.DOWN
PriceChangeType.NEUTRAL -> TokenPriceChangeDirection.NEUTRAL
}
val stakingText = when (val rate = item.stakingRate) {
is TextReference.Str -> rate.value
is TextReference.StyledStr -> rate.value
else -> null
}
return RecentSearchToken(
id = item.id,
name = item.name,
symbol = item.currencySymbol,
imageUrl = item.iconUrl,
timestamp = item.updateTimestamp ?: System.currentTimeMillis(),
appCurrencyCode = value.appCurrencyCode,
appCurrencySymbol = value.appCurrencySymbol,
price = item.price.fiatPrice,
priceChangePercent = item.trendPercentText,
priceChangeDirection = direction,
marketCap = item.marketCap,
ratingPosition = item.ratingPosition,
isUnder100kMarketCap = item.isUnder100kMarketCap,
stakingRateText = stakingText,
)
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.common.ui.markets.models.MarketsListItemUM
internal data class MarketsListItemUMWithAppCurrency(
val item: MarketsListItemUM,
val appCurrencyCode: String,
val appCurrencySymbol: String,
)

View file

@ -0,0 +1,55 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.search.model.TokenPriceChangeDirection
import com.tangem.utils.converter.Converter
internal class RecentSearchTokenToMarketsListItemUMConverter :
Converter<RecentSearchTokenWithAppCurrency, MarketsListItemUM> {
override fun convert(value: RecentSearchTokenWithAppCurrency): MarketsListItemUM {
val token = value.token
val currencyCode = token.appCurrencyCode.ifEmpty { value.appCurrency.code }
val currencySymbol = token.appCurrencySymbol.ifEmpty { value.appCurrency.symbol }
val trendType = when (token.priceChangeDirection) {
TokenPriceChangeDirection.UP -> PriceChangeType.UP
TokenPriceChangeDirection.DOWN -> PriceChangeType.DOWN
TokenPriceChangeDirection.NEUTRAL -> PriceChangeType.NEUTRAL
}
val priceText = token.price.format {
fiat(
fiatCurrencyCode = currencyCode,
fiatCurrencySymbol = currencySymbol,
).price()
}
return MarketsListItemUM(
id = token.id,
name = token.name,
currencySymbol = token.symbol,
iconUrl = token.imageUrl,
ratingPosition = token.ratingPosition,
marketCap = token.marketCap,
price = MarketsListItemUM.Price(
text = priceText,
annotated = token.price.toMarketsListItemPriceAnnotated(
appCurrencyCode = currencyCode,
appCurrencySymbol = currencySymbol,
),
changeType = null,
fiatPrice = token.price,
),
trendPercentText = token.priceChangePercent,
trendType = trendType,
chartData = null,
isUnder100kMarketCap = token.isUnder100kMarketCap,
stakingRate = token.stakingRateText?.let { TextReference.Str(it) },
updateTimestamp = token.timestamp,
)
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.features.feed.model.search.converter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.search.model.RecentSearchToken
internal data class RecentSearchTokenWithAppCurrency(
val token: RecentSearchToken,
val appCurrency: AppCurrency,
)

View file

@ -0,0 +1,43 @@
package com.tangem.features.feed.model.search.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.search.state.transformers.SearchUMTransformer
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class SearchStateController @Inject constructor() {
private val mutableUiState: MutableStateFlow<SearchUM> = MutableStateFlow(value = getInitialState())
val uiState: StateFlow<SearchUM> = mutableUiState.asStateFlow()
val value: SearchUM
get() = uiState.value
fun update(transformer: SearchUMTransformer) {
mutableUiState.update(function = transformer::transform)
}
private fun getInitialState(): SearchUM {
return SearchUM(
searchBar = SearchBarUM(
placeholderText = resourceReference(id = R.string.markets_search_title_placeholder),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
onClearClick = {},
),
content = SearchContentUM.InitialEmpty,
)
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
internal class ApplySearchMarketBatchTransformer(
private val snapshot: SearchMarketBatchUiSnapshot,
) : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
if (shouldIgnoreTransientMarketEmpty(prevState, snapshot)) return prevState
return UpdateMarketItemsTransformer(snapshot.marketResult).transform(prevState)
}
private fun shouldIgnoreTransientMarketEmpty(prevState: SearchUM, snapshot: SearchMarketBatchUiSnapshot): Boolean {
if (snapshot.marketResult !is MarketSearchResultUM.Empty) return false
if (snapshot.isSearchNotFound || snapshot.isInErrorState) return false
val prev = prevState.content as? SearchContentUM.Results ?: return false
return when (prev.marketTokens) {
is MarketSearchResultUM.Content, is MarketSearchResultUM.Loading -> true
else -> false
}
}
}
/** Inputs from the search market list flow before merging into [SearchUM]. */
internal data class SearchMarketBatchUiSnapshot(
val marketResult: MarketSearchResultUM,
val isSearchNotFound: Boolean,
val isInErrorState: Boolean,
)

View file

@ -0,0 +1,7 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.SearchUM
internal interface SearchUMTransformer {
fun transform(prevState: SearchUM): SearchUM
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import kotlinx.collections.immutable.persistentListOf
internal class SetSearchResultsLoadingTransformer : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
val currentContent = prevState.content
val currentUserAssets = if (currentContent is SearchContentUM.Results) {
currentContent.userAssets
} else {
persistentListOf()
}
// Keep showing the previous market list while the new query loads (stale-while-revalidate).
// Replacing with Loading + empty batch flashes skeletons / empty section even when the API
// returns the same tokens for a refined query.
val nextMarketTokens = if (currentContent is SearchContentUM.Results) {
when (val market = currentContent.marketTokens) {
is MarketSearchResultUM.Content -> market
else -> MarketSearchResultUM.Loading
}
} else {
MarketSearchResultUM.Loading
}
return prevState.copy(
content = SearchContentUM.Results(
userAssets = currentUserAssets,
marketTokens = nextMarketTokens,
),
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import com.tangem.features.feed.ui.search.state.TextHintItemUM
import kotlinx.collections.immutable.ImmutableList
internal class UpdateHistoryTransformer(
private val textHints: ImmutableList<TextHintItemUM>,
private val recentTokens: ImmutableList<MarketsListItemUM>,
) : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
return prevState.copy(
content = SearchContentUM.History(
textHints = textHints,
recentTokens = recentTokens,
),
)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import kotlinx.collections.immutable.persistentListOf
internal class UpdateMarketItemsTransformer(
private val marketResult: MarketSearchResultUM,
) : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
val currentContent = prevState.content
val currentUserAssets = if (currentContent is SearchContentUM.Results) {
currentContent.userAssets
} else {
persistentListOf()
}
return prevState.copy(
content = SearchContentUM.Results(
userAssets = currentUserAssets,
marketTokens = marketResult,
),
)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import kotlinx.collections.immutable.toImmutableList
internal class UpdateRecentTokenChartTransformer(
private val tokenId: CryptoCurrency.RawID,
private val chartData: MarketChartRawData,
) : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
val content = prevState.content
if (content !is SearchContentUM.History) return prevState
val updatedTokens = content.recentTokens.map { token ->
if (token.id == tokenId) {
token.copy(chartData = chartData)
} else {
token
}
}.toImmutableList()
return prevState.copy(
content = content.copy(recentTokens = updatedTokens),
)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.SearchUM
internal class UpdateSearchBarQueryTransformer(
private val query: String,
) : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(query = query),
)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.features.feed.model.search.state.transformers
import com.tangem.features.feed.ui.search.state.MarketSearchResultUM
import com.tangem.features.feed.ui.search.state.SearchContentUM
import com.tangem.features.feed.ui.search.state.SearchUM
import com.tangem.features.feed.ui.search.state.UserAssetItemUM
import kotlinx.collections.immutable.ImmutableList
internal class UpdateUserAssetsTransformer(
private val userAssets: ImmutableList<UserAssetItemUM>,
) : SearchUMTransformer {
override fun transform(prevState: SearchUM): SearchUM {
val currentContent = prevState.content
val currentMarketTokens = if (currentContent is SearchContentUM.Results) {
currentContent.marketTokens
} else {
MarketSearchResultUM.Loading
}
return prevState.copy(
content = SearchContentUM.Results(
userAssets = userAssets,
marketTokens = currentMarketTokens,
),
)
}
}

View file

@ -1,37 +1,66 @@
package com.tangem.features.feed.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.router.stack.ChildStack
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.feed.components.FeedEntryChildFactory
import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation
import com.tangem.features.feed.ui.utils.topBarFeedEntryStackAnimation
import com.tangem.features.feed.ui.utils.topBarFeedEntryAnimatedContentTransitionSpec
import dev.chrisbanes.haze.rememberHazeState
@OptIn(ExperimentalDecomposeApi::class)
@Composable
internal fun EntryContent(
bottomSheetState: State<BottomSheetState>,
stackState: State<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>>,
onHeaderSizeChange: (Dp) -> Unit,
isOpenedInBottomSheet: Boolean,
) {
if (LocalRedesignEnabled.current) {
EntryContentV2(
bottomSheetState = bottomSheetState,
stackState = stackState,
onHeaderSizeChange = onHeaderSizeChange,
isOpenedInBottomSheet = isOpenedInBottomSheet,
)
} else {
EntryContentV1(
bottomSheetState = bottomSheetState,
stackState = stackState,
onHeaderSizeChange = onHeaderSizeChange,
isOpenedInBottomSheet = isOpenedInBottomSheet,
)
}
}
@Composable
private fun EntryContentV1(
bottomSheetState: State<BottomSheetState>,
stackState: State<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>>,
onHeaderSizeChange: (Dp) -> Unit,
isOpenedInBottomSheet: Boolean,
) {
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
val animationContent = remember { contentFeedEntryStackAnimation() }
val animationAppBar = remember { topBarFeedEntryStackAnimation() }
val stackAnimation = remember { contentFeedEntryStackAnimation() }
Surface(contentColor = background) {
Scaffold(
@ -55,7 +84,7 @@ internal fun EntryContent(
}
},
stack = stackState.value,
animation = animationAppBar,
animation = stackAnimation,
) { child ->
child.instance.Title(bottomSheetState)
}
@ -63,14 +92,76 @@ internal fun EntryContent(
content = { contentPadding ->
Children(
stack = stackState.value,
animation = animationContent,
animation = stackAnimation,
) { child ->
child.instance.Content(
modifier = Modifier.padding(contentPadding),
bottomSheetState = bottomSheetState,
contentPadding = PaddingValues(),
)
}
},
)
}
}
@Composable
private fun EntryContentV2(
bottomSheetState: State<BottomSheetState>,
stackState: State<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>>,
onHeaderSizeChange: (Dp) -> Unit,
isOpenedInBottomSheet: Boolean,
) {
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
val animationContent = remember { contentFeedEntryStackAnimation() }
val animationAppBar = remember(stackState) { topBarFeedEntryAnimatedContentTransitionSpec(stackState) }
var topBarHeight by remember { mutableStateOf(0.dp) }
val hazeState = rememberHazeState()
Surface(contentColor = background) {
CompositionLocalProvider(LocalHazeState provides hazeState) {
Box(modifier = Modifier.fillMaxSize()) {
Children(
modifier = Modifier.fillMaxSize(),
stack = stackState.value,
animation = animationContent,
) { child ->
child.instance.Content(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = 0f, state = hazeState),
contentPadding = PaddingValues(top = topBarHeight),
bottomSheetState = bottomSheetState,
)
}
AnimatedContent(
modifier = Modifier
.align(Alignment.TopStart)
.then(
if (!isOpenedInBottomSheet) {
Modifier.statusBarsPadding()
} else {
Modifier
},
)
.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
val height = coordinates.size.height.toDp()
topBarHeight = height
onHeaderSizeChange(height)
}
}
},
targetState = stackState.value.active,
transitionSpec = animationAppBar,
contentKey = { it.key },
label = "FeedEntryAppBar",
) { state ->
state.instance.Title(bottomSheetState)
}
}
}
}
}

View file

@ -0,0 +1,136 @@
package com.tangem.features.feed.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.components.SpacerW
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
@Composable
internal fun FeedSearchBar(
isSearchBarClickable: Boolean,
feedListSearchBar: FeedListSearchBar,
modifier: Modifier = Modifier,
startContent: @Composable (() -> Unit)? = null,
endContent: @Composable (() -> Unit)? = null,
) {
if (LocalRedesignEnabled.current) {
FeedSearchBarV2(
isSearchBarClickable = isSearchBarClickable,
feedListSearchBar = feedListSearchBar,
modifier = modifier,
startContent = startContent,
endContent = endContent,
)
} else {
FeedSearchBarV1(
isSearchBarClickable = isSearchBarClickable,
feedListSearchBar = feedListSearchBar,
modifier = modifier,
)
}
}
@Composable
private fun FeedSearchBarV1(
isSearchBarClickable: Boolean,
feedListSearchBar: FeedListSearchBar,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(36.dp))
.background(color = TangemTheme.colors.field.focused)
.conditional(condition = isSearchBarClickable) {
clickable(onClick = feedListSearchBar.onBarClick)
}
.padding(14.dp),
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size20),
imageVector = ImageVector.vectorResource(id = R.drawable.ic_search_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
SpacerW(14.dp)
Text(
text = feedListSearchBar.placeholderText.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
private fun FeedSearchBarV2(
isSearchBarClickable: Boolean,
feedListSearchBar: FeedListSearchBar,
modifier: Modifier = Modifier,
startContent: @Composable (() -> Unit)? = null,
endContent: @Composable (() -> Unit)? = null,
) {
TangemTopBar(
modifier = modifier,
startContent = startContent,
endContent = endContent,
type = TangemTopBarType.BottomSheet,
reserveSlotSpace = false,
content = {
Row(
modifier = Modifier
.weight(1f)
.padding(
start = if (startContent != null) TangemTheme.dimens2.x3 else 0.dp,
end = if (endContent != null) TangemTheme.dimens2.x3 else 0.dp,
)
.clip(CircleShape)
.background(color = TangemTheme.colors2.button.backgroundSecondary)
.conditional(condition = isSearchBarClickable) {
clickable(onClick = feedListSearchBar.onBarClick)
}
.padding(TangemTheme.dimens2.x3),
horizontalArrangement = Arrangement.Center,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x5),
imageVector = ImageVector.vectorResource(id = R.drawable.ic_search_default_24),
tint = TangemTheme.colors2.markers.iconGray,
contentDescription = null,
)
SpacerW(TangemTheme.dimens2.x1)
Text(
text = feedListSearchBar.placeholderText.resolveReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
}

View file

@ -22,19 +22,18 @@ import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.*
import com.tangem.domain.models.earn.EarnType
import com.tangem.features.feed.ui.earn.components.*
import com.tangem.features.feed.ui.earn.state.*
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
import kotlinx.collections.immutable.persistentListOf
private const val EARN_LOAD_MORE_BUFFER = 3
@Composable
internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
internal fun EarnContent(contentPadding: PaddingValues, state: EarnUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
@ -53,7 +52,7 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
modifier = modifier
.fillMaxSize()
.background(background),
contentPadding = PaddingValues(bottom = bottomBarHeight),
contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()),
) {
item(key = "mostly_used_header") {
SectionHeader(
@ -393,6 +392,7 @@ private fun EarnContentPreviewV1() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
contentPadding = PaddingValues(),
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
@ -435,6 +435,7 @@ private fun EarnContentPreviewV2() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
contentPadding = PaddingValues(),
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
@ -477,6 +478,7 @@ private fun EarnContentLoadingPreviewV1() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
contentPadding = PaddingValues(),
state = previewEarnUM(
mostlyUsed = EarnListUM.Error(onRetryClicked = {}),
bestOpportunities = EarnBestOpportunitiesUM.Loading,
@ -496,6 +498,7 @@ private fun EarnContentLoadingPreviewV2() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
contentPadding = PaddingValues(),
state = previewEarnUM(
mostlyUsed = EarnListUM.Error(onRetryClicked = {}),
bestOpportunities = EarnBestOpportunitiesUM.Loading,
@ -515,6 +518,7 @@ private fun EarnContentErrorPreviewV1() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
contentPadding = PaddingValues(),
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
@ -543,6 +547,7 @@ private fun EarnContentErrorPreviewV2() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
contentPadding = PaddingValues(),
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
@ -571,6 +576,7 @@ private fun EarnContentEmptyPreviewV1() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
contentPadding = PaddingValues(),
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
@ -599,6 +605,7 @@ private fun EarnContentEmptyPreviewV2() {
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
EarnContent(
contentPadding = PaddingValues(),
state = previewEarnUM(
mostlyUsed = EarnListUM.Content(
items = persistentListOf(
@ -635,7 +642,8 @@ private fun previewEarnListItemUM(
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
earnTypeTitle = stringReference("Yield"),
earnType = EarnType.YIELD,
onItemClick = {},
)
@ -655,6 +663,10 @@ private fun previewEarnUM(
onNetworkFilterClick = {},
onTypeFilterClick = {},
onSliderScroll = {},
feedListSearchBar = FeedListSearchBar(
placeholderText = TextReference.Str("Search tokens & news"),
onBarClick = {},
),
)
private const val PLACEHOLDER_ITEMS_COUNT = 8

View file

@ -30,6 +30,7 @@ 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.res.*
import com.tangem.domain.models.earn.EarnType
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
@Composable
@ -95,7 +96,7 @@ private fun EarnListItemV1(item: EarnListItemUM, modifier: Modifier = Modifier)
)
SpacerH(2.dp)
Text(
text = item.earnType.resolveReference(),
text = item.earnTypeTitle.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption2,
maxLines = 1,
@ -147,6 +148,7 @@ private fun EarnListItemV2(item: EarnListItemUM, modifier: Modifier = Modifier)
ModeBlock(
modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM),
earnType = item.earnType,
earnTypeTitle = item.earnTypeTitle,
)
},
)
@ -200,19 +202,24 @@ private fun TokenTitle(name: String, symbol: String, modifier: Modifier = Modifi
}
@Composable
private fun ModeBlock(earnType: TextReference, modifier: Modifier = Modifier) {
private fun ModeBlock(earnType: EarnType, earnTypeTitle: TextReference, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_staking_new_16),
imageVector = ImageVector.vectorResource(
id = when (earnType) {
EarnType.STAKING -> R.drawable.ic_staking_new_16
EarnType.YIELD -> R.drawable.ic_yield_mode_16
},
),
tint = TangemTheme.colors2.markers.iconGray,
contentDescription = null,
)
Text(
text = earnType.resolveReference(),
text = earnTypeTitle.resolveReference(),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.tertiary,
)
@ -239,7 +246,8 @@ private fun EarnListItemPreviewV1() {
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 8.50%"),
earnType = stringReference("Yield"),
earnTypeTitle = stringReference("Yield"),
earnType = EarnType.YIELD,
onItemClick = {},
),
)
@ -257,7 +265,8 @@ private fun EarnListItemPreviewV1() {
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 8.50%"),
earnType = stringReference("Yield"),
earnTypeTitle = stringReference("Yield"),
earnType = EarnType.YIELD,
onItemClick = {},
),
)
@ -286,7 +295,8 @@ private fun EarnListItemPreviewV2() {
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 8.50%"),
earnType = stringReference("Yield"),
earnTypeTitle = stringReference("Yield"),
earnType = EarnType.YIELD,
onItemClick = {},
),
)
@ -305,7 +315,8 @@ private fun EarnListItemPreviewV2() {
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 8.50%"),
earnType = stringReference("Yield"),
earnTypeTitle = stringReference("Yield"),
earnType = EarnType.YIELD,
onItemClick = {},
),
)

View file

@ -21,6 +21,7 @@ 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.domain.models.earn.EarnType
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
@Composable
@ -161,7 +162,8 @@ private fun EarnListItemPreviewV1() {
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
earnTypeTitle = stringReference("Yield"),
earnType = EarnType.YIELD,
onItemClick = {},
),
onClick = {},
@ -187,7 +189,8 @@ private fun EarnListItemPreviewV2() {
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
earnTypeTitle = stringReference("Yield"),
earnType = EarnType.YIELD,
onItemClick = {},
),
onClick = {},

View file

@ -3,6 +3,7 @@ package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.earn.EarnType
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -32,6 +33,7 @@ internal data class EarnListItemUM(
val tokenName: TextReference,
val currencyIconState: CurrencyIconState,
val earnValue: TextReference,
val earnType: TextReference,
val earnType: EarnType,
val earnTypeTitle: TextReference,
val onItemClick: () -> Unit,
)

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.earn.state
import androidx.compose.runtime.Immutable
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
@Immutable
internal data class EarnUM(
@ -11,4 +12,5 @@ internal data class EarnUM(
val onNetworkFilterClick: () -> Unit,
val onTypeFilterClick: () -> Unit,
val onSliderScroll: () -> Unit,
val feedListSearchBar: FeedListSearchBar,
)

View file

@ -1,42 +1,35 @@
package com.tangem.features.feed.ui.feed
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.decompose.ComposableContentComponent
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.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BaseSearchBarTestTags.SEARCH_BAR
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.components.FeedSearchBar
import com.tangem.features.feed.ui.feed.components.*
import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
import com.tangem.features.feed.ui.feed.state.FeedListUM
import com.tangem.features.feed.ui.feed.state.GlobalFeedState
import dev.chrisbanes.haze.HazeProgressive
@Composable
internal fun FeedListHeader(
@ -50,14 +43,26 @@ internal fun FeedListHeader(
feedListSearchBar = feedListSearchBar,
modifier = modifier
.drawBehind { drawRect(background) }
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
.conditionalCompose(
condition = LocalRedesignEnabled.current,
modifier = {
hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(
startIntensity = .75f,
endIntensity = 0f,
preferPerformance = true,
easing = EaseOut,
)
}
},
)
.testTag(SEARCH_BAR),
)
}
@Composable
internal fun FeedList(
contentPadding: PaddingValues,
state: FeedListUM,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
@ -73,19 +78,23 @@ internal fun FeedList(
FeedListLoading(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.drawBehind { drawRect(background) },
.drawBehind { drawRect(background) }
.padding(top = contentPadding.calculateTopPadding())
.verticalScroll(rememberScrollState()),
)
}
is GlobalFeedState.Error -> {
FeedListGlobalError(
onRetryClick = animatedState.onRetryClicked,
modifier = Modifier.drawBehind { drawRect(background) },
modifier = Modifier
.drawBehind { drawRect(background) }
.padding(top = contentPadding.calculateTopPadding()),
currentDate = state.currentDate,
)
}
is GlobalFeedState.Content -> {
FeedListContent(
contentPadding = contentPadding,
modifier = Modifier,
state = state,
promoBannersBlockComponent = promoBannersBlockComponent,
@ -95,41 +104,9 @@ internal fun FeedList(
}
}
@Composable
private fun FeedSearchBar(
isSearchBarClickable: Boolean,
feedListSearchBar: FeedListSearchBar,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(36.dp))
.background(color = TangemTheme.colors.field.focused)
.conditional(condition = isSearchBarClickable) {
clickable(onClick = feedListSearchBar.onBarClick)
}
.padding(14.dp),
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size20),
imageVector = ImageVector.vectorResource(id = R.drawable.ic_search_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
SpacerW(14.dp)
Text(
text = feedListSearchBar.placeholderText.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
private fun FeedListContent(
contentPadding: PaddingValues,
state: FeedListUM,
modifier: Modifier = Modifier,
promoBannersBlockComponent: ComposableContentComponent? = null,
@ -142,6 +119,9 @@ private fun FeedListContent(
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.drawBehind { drawRect(background) },
) {
if (LocalRedesignEnabled.current) {
SpacerH(contentPadding.calculateTopPadding())
}
DateBlock(state.currentDate)
SpacerH(32.dp)
@ -174,6 +154,6 @@ private fun FeedListContent(
@Composable
private fun FeedListPreview() {
TangemThemePreview {
FeedList(state = createFeedPreviewState())
FeedList(state = createFeedPreviewState(), contentPadding = PaddingValues())
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.res.vectorResource
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.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
@ -23,13 +24,16 @@ import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun Header(
internal fun ColumnScope.Header(
onSeeAllClick: () -> Unit,
isLoading: Boolean,
shouldShowSeeAll: Boolean,
title: @Composable () -> Unit,
) {
val isRedesignEnabled = LocalRedesignEnabled.current
if (isRedesignEnabled) {
SpacerH(20.dp)
}
AnimatedContent(isLoading) { animatedState ->
Row(
modifier = Modifier

View file

@ -9,10 +9,20 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun DateBlock(currentDate: String) {
if (LocalRedesignEnabled.current) {
DateBlockV2(currentDate)
} else {
DateBlockV1(currentDate)
}
}
@Composable
private fun DateBlockV1(currentDate: String) {
SpacerH(20.dp)
Text(
modifier = Modifier
@ -30,4 +40,26 @@ internal fun DateBlock(currentDate: String) {
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun DateBlockV2(currentDate: String) {
SpacerH(TangemTheme.dimens2.x1)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
text = stringResourceSafe(R.string.feed_market_and_news),
style = TangemTheme.typography2.headingRegular28,
color = TangemTheme.colors2.text.neutral.primary,
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
text = currentDate,
style = TangemTheme.typography2.headingRegular28,
color = TangemTheme.colors2.text.neutral.tertiary,
)
SpacerH(TangemTheme.dimens2.x6)
}

View file

@ -71,7 +71,7 @@ internal fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedLis
isLoading = currentChart is MarketChartUM.Loading,
)
SpacerH(12.dp)
SpacerH(if (isRedesignEnabled) 20.dp else 12.dp)
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
@ -89,7 +89,7 @@ internal fun MarketBlock(marketChart: MarketChartUM?, feedListCallbacks: FeedLis
@Suppress("LongMethod")
@Composable
internal fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
internal fun ColumnScope.MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
val isRedesignEnabled = LocalRedesignEnabled.current
val onSeeAllClick by rememberUpdatedState {
feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType)

View file

@ -17,11 +17,10 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
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,6 +30,8 @@ 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.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.feed.state.*
internal const val FOURTH_ITEM_INDEX = 3
@ -87,8 +88,16 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.common_news),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
style = if (isRedesignEnabled) {
TangemTheme.typography2.headingSemibold20
} else {
TangemTheme.typography.h3
},
color = if (isRedesignEnabled) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors.text.primary1
},
)
SpacerW(4.dp)
@ -113,7 +122,11 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM,
append(stringResourceSafe(R.string.feed_tangem_ai))
}
},
style = TangemTheme.typography.subtitle1,
style = if (isRedesignEnabled) {
TangemTheme.typography2.bodyRegular16.copy(fontWeight = FontWeight.Medium)
} else {
TangemTheme.typography.subtitle1
},
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)

View file

@ -11,12 +11,14 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.earn.EarnType
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
import com.tangem.features.feed.ui.earn.state.EarnListUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.feed.state.*
import kotlinx.collections.immutable.*
import java.math.BigDecimal
@Suppress("MagicNumber")
internal object FeedListPreviewDataProvider {
@ -246,7 +248,11 @@ internal object FeedListPreviewDataProvider {
iconUrl = null,
ratingPosition = rating,
marketCap = marketCap,
price = MarketsListItemUM.Price(text = "31 285.72$"),
price = MarketsListItemUM.Price(
text = "31 285.72$",
fiatPrice = BigDecimal(123123),
annotated = stringReference("31 285.72$"),
),
trendPercentText = percent,
trendType = trendType,
chartData = MarketChartRawData(
@ -273,7 +279,8 @@ internal object FeedListPreviewDataProvider {
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
earnTypeTitle = stringReference("Yield"),
earnType = EarnType.YIELD,
onItemClick = {},
)
}.toPersistentList()

View file

@ -32,9 +32,12 @@ import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.currency.icon.CoinIcon
import com.tangem.core.ui.components.marketprice.PriceChangeInPercent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalRedesignEnabled
@ -49,18 +52,21 @@ import com.tangem.features.feed.ui.market.detailed.state.InfoBottomSheetContent
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.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import com.tangem.core.ui.R as CoreR
@Suppress("LongParameterList")
@Composable
internal fun MarketsTokenDetailsContent(
contentPadding: PaddingValues,
state: MarketsTokenDetailsUM,
backgroundColor: Color,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
Content(
contentPadding = contentPadding,
modifier = modifier,
backgroundColor = backgroundColor,
state = state,
@ -77,6 +83,7 @@ internal fun MarketsTokenDetailsContent(
@Suppress("LongParameterList")
@Composable
private fun Content(
contentPadding: PaddingValues,
state: MarketsTokenDetailsUM,
backgroundColor: Color,
modifier: Modifier = Modifier,
@ -100,7 +107,7 @@ private fun Content(
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight),
contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()),
) {
item("header") {
Header(
@ -176,6 +183,7 @@ private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier)
Column(modifier = Modifier.weight(1f)) {
TokenPriceText(
price = state.priceText,
priceAnnotated = state.priceAnnotated,
triggerPriceChange = state.triggerPriceChange,
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
@ -206,6 +214,28 @@ private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier)
@Composable
private fun TokenPriceText(
price: String,
triggerPriceChange: StateEvent<PriceChangeType>,
priceAnnotated: TextReference,
modifier: Modifier = Modifier,
) {
if (LocalRedesignEnabled.current) {
TokenPriceTextV2(
priceAnnotated = priceAnnotated,
triggerPriceChange = triggerPriceChange,
modifier = modifier,
)
} else {
TokenPriceTextV1(
price = price,
triggerPriceChange = triggerPriceChange,
modifier = modifier,
)
}
}
@Composable
private fun TokenPriceTextV1(
price: String,
triggerPriceChange: StateEvent<PriceChangeType>,
modifier: Modifier = Modifier,
@ -238,6 +268,40 @@ private fun TokenPriceText(
)
}
@Composable
private fun TokenPriceTextV2(
priceAnnotated: TextReference,
triggerPriceChange: StateEvent<PriceChangeType>,
modifier: Modifier = Modifier,
) {
val growColor = TangemTheme.colors2.graphic.status.accent
val fallColor = TangemTheme.colors2.graphic.status.warning
val generalColor = TangemTheme.colors2.text.neutral.primary
val color = remember(generalColor) { Animatable(generalColor) }
EventEffect(triggerPriceChange) { priceChangeType ->
val nextColor = when (priceChangeType) {
PriceChangeType.UP,
-> growColor
PriceChangeType.DOWN -> fallColor
PriceChangeType.NEUTRAL -> return@EventEffect
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
Text(
text = priceAnnotated.resolveAnnotatedReference(),
modifier = modifier,
color = color.value,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography2.headingBold34.fontSize),
maxLines = 1,
style = TangemTheme.typography2.headingBold34,
)
}
@Composable
private fun IntervalSelector(
trendInterval: PriceChangeInterval,
@ -245,6 +309,26 @@ private fun IntervalSelector(
onIntervalClick: (PriceChangeInterval) -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalRedesignEnabled.current) {
val items = remember {
PriceChangeInterval.entries
.map { TangemSegmentUM(it.toString(), it.getText()) }.toImmutableList()
}
val selectedItem = remember(trendInterval) {
items.firstOrNull { it.id == trendInterval.toString() }
}
TangemSegmentedPicker(
items = items,
initialSelectedItem = selectedItem,
isFixed = true,
modifier = modifier,
onClick = { onIntervalClick(PriceChangeInterval.valueOf(it.id)) },
)
return
}
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
@ -296,7 +380,6 @@ private fun ShowPriceSubtitleEffect(lazyListState: LazyListState, onShouldShowPr
}
}
@Composable
fun PriceChangeInterval.getText(): TextReference {
return when (this) {
PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title)
@ -321,6 +404,7 @@ private fun MarketsTokenDetailsContent_Preview(
state = params,
backgroundColor = TangemTheme.colors.background.tertiary,
portfolioBlock = {},
contentPadding = PaddingValues(),
)
}
}

View file

@ -111,20 +111,23 @@ private fun InsightsBlockV1(state: InsightsUM, modifier: Modifier = Modifier) {
@Composable
private fun InsightsBlockV2(state: InsightsUM, modifier: Modifier = Modifier) {
val segmentItems = persistentListOf(
TangemSegmentUM(
id = PriceChangeInterval.H24.name,
title = resourceReference(R.string.markets_token_details_insight_day_timeline),
),
TangemSegmentUM(
id = PriceChangeInterval.WEEK.name,
title = resourceReference(R.string.markets_token_details_insight_week_timeline),
),
TangemSegmentUM(
id = PriceChangeInterval.MONTH.name,
title = resourceReference(R.string.markets_token_details_insight_month_timeline),
),
)
val segmentItems = remember {
persistentListOf(
TangemSegmentUM(
id = PriceChangeInterval.H24.name,
title = resourceReference(R.string.markets_token_details_insight_day_timeline),
),
TangemSegmentUM(
id = PriceChangeInterval.WEEK.name,
title = resourceReference(R.string.markets_token_details_insight_week_timeline),
),
TangemSegmentUM(
id = PriceChangeInterval.MONTH.name,
title = resourceReference(R.string.markets_token_details_insight_month_timeline),
),
)
}
var currentInterval by remember { mutableStateOf(segmentItems.first()) }
TokenMarketInformationBlock(
@ -143,7 +146,6 @@ private fun InsightsBlockV2(state: InsightsUM, modifier: Modifier = Modifier) {
TangemSegmentedPicker(
items = segmentItems,
initialSelectedItem = segmentItems.first(),
hasSeparator = true,
isFixed = false,
isAltSurface = true,
minSegmentWidth = 48.dp,

View file

@ -21,14 +21,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.progressbar.TangemLinearProgressIndicator
import com.tangem.core.ui.ds.progress.TangemLinearProgressIndicatorWithDot
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@ -36,6 +34,7 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.components.MetricsCard
import com.tangem.features.feed.ui.market.detailed.state.InfoPointUMV2
import com.tangem.features.feed.ui.market.detailed.state.MarketRatingChange24H
import com.tangem.features.feed.ui.market.detailed.state.MarketRatingType
import com.tangem.features.feed.ui.market.detailed.state.TrendingVolumeLiquidityType
@ -45,15 +44,7 @@ internal fun MarketCapCard(item: InfoPointUMV2.MarketCap) {
modifier = Modifier
.heightIn(120.dp)
.fillMaxWidth(),
title = {
Text(
text = item.capitalizationValue.resolveReference(),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
title = { MetricValueText(value = item.capitalizationValue) },
content = {
InformationTextBlock(
text = resourceReference(R.string.markets_token_details_market_capitalization),
@ -69,25 +60,22 @@ internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) {
TrendingVolumeLiquidityType.HIGH -> TangemTheme.colors2.markers.backgroundSolidGreen
TrendingVolumeLiquidityType.MEDIUM -> TangemTheme.colors2.graphic.status.attention
TrendingVolumeLiquidityType.LOW -> TangemTheme.colors2.graphic.status.warning
TrendingVolumeLiquidityType.UNKNOWN -> TangemTheme.colors2.surface.level3
}
val valueColor = metricValueColor(hasData = item.tradingValue != null)
MetricsCard(
modifier = Modifier
.heightIn(120.dp)
.fillMaxWidth(),
title = {
Row {
Text(
text = item.tradingValue.resolveReference(),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
MetricValueText(item.tradingValue)
Text(
modifier = Modifier.padding(TangemTheme.dimens2.x1),
text = stringResourceSafe(R.string.markets_token_details_trading_interval),
style = TangemTheme.typography2.captionSemibold11,
color = TangemTheme.colors2.text.neutral.primary,
color = valueColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -95,14 +83,17 @@ internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) {
},
content = {
Column(modifier = Modifier.fillMaxWidth()) {
TangemLinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(6.dp),
progress = { item.liquidity },
color = tradingColor,
backgroundColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant.copy(alpha = .1f),
)
if (item.liquidity != null) {
TangemLinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(6.dp),
progress = { item.liquidity },
color = tradingColor,
backgroundColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant
.copy(alpha = .1f),
)
}
SpacerH(12.dp)
InformationTextBlock(
text = resourceReference(R.string.markets_token_details_trading_volume),
@ -127,37 +118,25 @@ internal fun MarketPositionCard(item: InfoPointUMV2.MarketPosition) {
.fillMaxWidth(),
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_big_laurel_left_20),
tint = ratingColor,
contentDescription = null,
)
Text(
textAlign = TextAlign.Center,
text = item.position,
color = ratingColor,
style = TangemTheme.typography2.headingSemibold20.copy(letterSpacing = 0.sp),
maxLines = 1,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_big_laurel_right_20),
tint = ratingColor,
contentDescription = null,
)
MarketPositionValue(position = item.position, ratingColor = ratingColor)
if (item.position != null) {
SpacerW(6.dp)
RatingChangeIndicator(change = item.marketRatingChange24H)
}
}
},
content = {
Column(modifier = Modifier.fillMaxWidth()) {
TangemLinearProgressIndicatorWithDot(
modifier = Modifier
.fillMaxWidth()
.height(6.dp),
progress = { item.rangeValue },
dotColor = TangemTheme.colors2.fill.neutral.primaryInvertedConstant,
backgroundColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant.copy(alpha = .1f),
)
if (item.rangeValue != null) {
TangemLinearProgressIndicatorWithDot(
modifier = Modifier
.fillMaxWidth()
.height(6.dp),
progress = { item.rangeValue },
dotColor = TangemTheme.colors2.fill.neutral.primaryInvertedConstant,
backgroundColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant.copy(alpha = .1f),
)
}
SpacerH(12.dp)
InformationTextBlock(
text = resourceReference(R.string.markets_token_details_market_rating),
@ -178,19 +157,41 @@ internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) {
.heightIn(120.dp)
.fillMaxWidth(),
title = {
Text(
text = item.value.resolveReference(),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.fullyDilutedValuationChange24 != null) {
Row {
MetricValueText(value = item.fullyDilutedValuationChange24)
Text(
modifier = Modifier.padding(TangemTheme.dimens2.x1),
text = stringResourceSafe(R.string.markets_token_details_trading_interval),
style = TangemTheme.typography2.captionSemibold11,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
MetricValueText(value = item.value)
}
},
content = {
InformationTextBlock(
text = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
onInfoClick = item.onInfoClick,
)
Column {
if (item.fullyDilutedValuationChange24 != null) {
Text(
text = item.value?.resolveReference()
?: stringResourceSafe(R.string.token_market_metrics_no_data),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
SpacerH(4.dp)
}
InformationTextBlock(
text = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
onInfoClick = item.onInfoClick,
)
}
},
)
}
@ -212,15 +213,11 @@ internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) {
overflow = TextOverflow.Ellipsis,
)
Text(
MetricValueText(
value = item.currentValue,
modifier = Modifier
.padding(top = 12.dp)
.layoutId(TangemRowLayoutId.START_BOTTOM),
text = item.currentValue.resolveReference(),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
@ -238,7 +235,7 @@ internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) {
.padding(top = 12.dp)
.layoutId(TangemRowLayoutId.END_BOTTOM),
text = item.maxValue.resolveReference(),
style = TangemTheme.typography2.headingSemibold20,
style = TangemTheme.typography2.headingSemibold22,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -253,7 +250,8 @@ internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) {
.fillMaxWidth()
.height(6.dp),
color = TangemTheme.colors2.graphic.status.accent,
trackColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant.copy(alpha = .1f),
trackColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant
.copy(alpha = .1f),
progress = { item.fillValue },
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
@ -265,6 +263,87 @@ internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) {
)
}
// region Private helpers
@Composable
private fun MetricValueText(value: TextReference?, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = value?.resolveReference() ?: stringResourceSafe(R.string.token_market_metrics_no_data),
style = TangemTheme.typography2.headingSemibold22,
color = metricValueColor(hasData = value != null),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun metricValueColor(hasData: Boolean): Color {
return if (hasData) TangemTheme.colors2.text.neutral.primary else TangemTheme.colors2.text.neutral.tertiary
}
@Composable
private fun MarketPositionValue(position: TextReference?, ratingColor: Color) {
if (position != null) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_big_laurel_left_20),
tint = ratingColor,
contentDescription = null,
)
Text(
textAlign = TextAlign.Center,
text = position.resolveReference(),
color = ratingColor,
style = TangemTheme.typography2.headingSemibold22.copy(letterSpacing = 0.sp),
maxLines = 1,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_big_laurel_right_20),
tint = ratingColor,
contentDescription = null,
)
} else {
MetricValueText(value = null)
}
}
@Composable
private fun RatingChangeIndicator(change: MarketRatingChange24H) {
when (change) {
is MarketRatingChange24H.Up -> RatingChangeContent(
iconRes = R.drawable.ic_arrow_up_8,
iconTint = TangemTheme.colors2.markers.iconGreen,
changeValue = change.changeValue.toString(),
textColor = TangemTheme.colors2.text.status.positive,
)
is MarketRatingChange24H.Down -> RatingChangeContent(
iconRes = R.drawable.ic_arrow_down_8,
iconTint = TangemTheme.colors2.markers.iconRed,
changeValue = change.changeValue.toString(),
textColor = TangemTheme.colors2.text.status.warning,
)
MarketRatingChange24H.NoChanges -> Unit
}
}
@Composable
private fun RatingChangeContent(iconRes: Int, iconTint: Color, changeValue: String, textColor: Color) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x3),
imageVector = ImageVector.vectorResource(id = iconRes),
tint = iconTint,
contentDescription = null,
)
SpacerW(2.dp)
Text(
text = changeValue,
style = TangemTheme.typography2.captionSemibold12,
color = textColor,
)
}
}
@Composable
private fun MarketRatingType.baseColor(): Color {
val isDarkTheme = LocalIsInDarkTheme.current
@ -295,6 +374,8 @@ private fun mapRatingToCardColor(marketRatingType: MarketRatingType): Color {
}
}
// endregion
private const val GOLD_PLACE_COLOR_NIGHT = 0xFFFBEE76
private const val GOLD_PLACE_COLOR_LIGHT = 0xFFD9B900
private const val SILVER_PLACE_COLOR_NIGHT = 0xFFAABEF7
@ -353,17 +434,19 @@ private fun MetricsCardsPreview() {
MarketPositionCard(
item = InfoPointUMV2.MarketPosition(
position = "1",
position = stringReference("1"),
rangeValue = 0.02f,
marketRatingType = MarketRatingType.GOLD,
onInfoClick = {},
marketRatingChange24H = MarketRatingChange24H.NoChanges,
),
)
MarketPositionCard(
item = InfoPointUMV2.MarketPosition(
position = "2",
position = stringReference("2"),
rangeValue = 0.05f,
marketRatingChange24H = MarketRatingChange24H.Up(1),
marketRatingType = MarketRatingType.SILVER,
onInfoClick = {},
),
@ -371,25 +454,28 @@ private fun MetricsCardsPreview() {
MarketPositionCard(
item = InfoPointUMV2.MarketPosition(
position = "3",
rangeValue = 0.08f,
marketRatingType = MarketRatingType.BRONZE,
position = null,
rangeValue = null,
marketRatingType = MarketRatingType.OTHER,
onInfoClick = {},
marketRatingChange24H = MarketRatingChange24H.NoChanges,
),
)
MarketPositionCard(
item = InfoPointUMV2.MarketPosition(
position = "42",
position = stringReference("42"),
rangeValue = 0.42f,
marketRatingType = MarketRatingType.OTHER,
onInfoClick = {},
marketRatingChange24H = MarketRatingChange24H.Down(15),
),
)
FDVCard(
item = InfoPointUMV2.FullyDilutedValuation(
value = stringReference("$ 1.5 T"),
fullyDilutedValuationChange24 = stringReference("$ 2.44 M in total"),
onInfoClick = {},
),
)

View file

@ -50,6 +50,7 @@ internal object MarketsTokenDetailsPreview {
onScroll = {},
),
onShareClick = {},
priceAnnotated = stringReference("$0.00000000324"),
)
val contentState = MarketsTokenDetailsUM(
@ -144,5 +145,6 @@ internal object MarketsTokenDetailsPreview {
onScroll = {},
),
onShareClick = {},
priceAnnotated = stringReference("$0.00000000324"),
)
}

View file

@ -2,18 +2,19 @@ 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.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
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
internal data class MarketsTokenDetailsUM(
val tokenName: String,
val priceText: String,
val priceAnnotated: TextReference,
val iconUrl: String?,
val dateTimeText: TextReference,
val priceChangePercentText: String?,

View file

@ -14,35 +14,37 @@ internal sealed interface InfoPointUMV2 {
@Immutable
data class MarketCap(
val capitalizationValue: TextReference,
val capitalizationValue: TextReference?,
val onInfoClick: () -> Unit,
) : InfoPointUMV2
@Immutable
data class TradingVolume(
val tradingValue: TextReference,
val liquidity: Float,
val tradingValue: TextReference?,
val liquidity: Float?,
val trendingVolumeLiquidityType: TrendingVolumeLiquidityType,
val onInfoClick: () -> Unit,
) : InfoPointUMV2
@Immutable
data class MarketPosition(
val position: String,
val rangeValue: Float,
val position: TextReference?,
val rangeValue: Float?,
val marketRatingChange24H: MarketRatingChange24H,
val marketRatingType: MarketRatingType,
val onInfoClick: () -> Unit,
) : InfoPointUMV2
@Immutable
data class FullyDilutedValuation(
val value: TextReference,
val value: TextReference?,
val fullyDilutedValuationChange24: TextReference?,
val onInfoClick: () -> Unit,
) : InfoPointUMV2
@Immutable
data class CirculatingSupply(
val currentValue: TextReference,
val currentValue: TextReference?,
val maxValue: TextReference?,
val fillValue: Float?,
val onInfoClick: () -> Unit,
@ -60,9 +62,18 @@ internal data class MetricsV2UM(
}
internal enum class TrendingVolumeLiquidityType {
HIGH, MEDIUM, LOW,
HIGH, MEDIUM, LOW, UNKNOWN
}
internal enum class MarketRatingType {
GOLD, SILVER, BRONZE, OTHER
}
internal sealed interface MarketRatingChange24H {
data class Up(val changeValue: Int) : MarketRatingChange24H
data class Down(val changeValue: Int) : MarketRatingChange24H
data object NoChanges : MarketRatingChange24H
}

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
@ -18,33 +17,30 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider
import com.tangem.core.ui.components.Keyboard
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH8
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
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.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.keyboardAsState
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.market.list.state.*
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
import com.tangem.features.feed.ui.market.list.components.MarketsListLazyColumn
import com.tangem.features.feed.ui.market.list.components.MarketsListSortByBottomSheet
import kotlinx.collections.immutable.persistentListOf
import com.tangem.features.feed.ui.market.list.components.Options
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@ -106,7 +102,7 @@ internal fun TopBarWithSearch(
}
@Composable
internal fun MarketsList(state: MarketsListUM, modifier: Modifier = Modifier) {
internal fun MarketsList(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
Column(
modifier = modifier
@ -114,7 +110,7 @@ internal fun MarketsList(state: MarketsListUM, modifier: Modifier = Modifier) {
.imePadding()
.drawBehind { drawRect(background) },
) {
Content(state = state)
Content(state = state, contentPadding = contentPadding)
}
MarketsListSortByBottomSheet(config = state.sortByBottomSheet)
KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown)
@ -122,11 +118,21 @@ internal fun MarketsList(state: MarketsListUM, modifier: Modifier = Modifier) {
@Suppress("LongMethod")
@Composable
private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modifier) {
val strokeColor = TangemTheme.colors.stroke.primary
private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) {
val isRedesignEnabled = LocalRedesignEnabled.current
val hazeState = rememberHazeState()
val strokeColor = if (isRedesignEnabled) {
TangemTheme.colors2.border.neutral.primary
} else {
TangemTheme.colors.stroke.primary
}
val scrolledState = remember { mutableStateOf(false) }
Column(modifier.padding(horizontal = TangemTheme.dimens.size16)) {
SpacerH(contentPadding.calculateTopPadding())
AnimatedVisibility(
visible = scrolledState.value.not() &&
state.isInSearchMode &&
@ -145,11 +151,19 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif
Column {
AnimatedVisibility(!state.isInSearchMode && !state.marketsSearchBar.shouldAlwaysShowSearchBar) {
Options(
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
modifier = Modifier.padding(
bottom = if (isRedesignEnabled) {
TangemTheme.dimens2.x2
} else {
TangemTheme.dimens.spacing12
},
),
sortByTypeUM = state.selectedSortBy,
trendInterval = state.selectedInterval,
onIntervalClick = state.onIntervalClick,
onSortByClick = state.onSortByButtonClick,
sortMenuUM = state.sortByMenuUM,
hazeState = hazeState,
)
}
}
@ -172,65 +186,18 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif
},
)
ItemsList(
modifier = Modifier.conditionalCompose(
condition = isRedesignEnabled,
modifier = {
hazeSourceTangem(zIndex = 0f, state = hazeState)
},
),
scrolledState = scrolledState,
isInSearchMode = state.isInSearchMode,
state = state.list,
)
}
@Composable
private fun Options(
sortByTypeUM: SortByTypeUM,
trendInterval: MarketsListUM.TrendInterval,
onSortByClick: () -> Unit,
onIntervalClick: (MarketsListUM.TrendInterval) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.height(IntrinsicSize.Max)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
SecondarySmallButton(
config = SmallButtonConfig(
text = sortByTypeUM.text,
onClick = onSortByClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
),
)
SegmentedButtons(
config = persistentListOf(
MarketsListUM.TrendInterval.H24,
MarketsListUM.TrendInterval.D7,
MarketsListUM.TrendInterval.M1,
),
color = TangemTheme.colors.button.secondary,
initialSelectedItem = trendInterval,
onClick = onIntervalClick,
modifier = Modifier
.width(160.dp)
.fillMaxHeight(),
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.text.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
}
@Composable
private fun ItemsList(
scrolledState: MutableState<Boolean>,
@ -307,6 +274,7 @@ private fun Preview() {
LocalMainBottomSheetColor provides remember { mutableStateOf(primaryBackground) },
) {
MarketsList(
contentPadding = PaddingValues(),
state = MarketsListUM(
list = ListUM.Content(
items = MarketChartListItemPreviewDataProvider().values
@ -343,6 +311,14 @@ private fun Preview() {
content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {},
),
onSearchClicked = {},
feedListSearchBar = FeedListSearchBar(
onBarClick = {},
placeholderText = resourceReference(id = R.string.markets_search_title_placeholder),
),
sortByMenuUM = SortByMenuUM(
selectedOption = SortByTypeUM.Rating,
onOptionClicked = {},
),
),
)
}

View file

@ -3,7 +3,7 @@ package com.tangem.features.feed.ui.market.list.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM.Companion.TOKEN_LAZY_LIST_ID_SEPARATOR
@ -19,9 +20,12 @@ 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.list.InfiniteListHandler
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.domain.models.currency.CryptoCurrency
@ -40,6 +44,7 @@ internal fun MarketsListLazyColumn(
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
val isRedesignEnabled = LocalRedesignEnabled.current
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
val coroutineScope = rememberCoroutineScope()
@ -93,11 +98,22 @@ internal fun MarketsListLazyColumn(
}
}
is ListUM.Content -> {
items(
itemsIndexed(
items = state.items,
key = { it.getComposeKey() },
) { item ->
key = { _, item -> item.getComposeKey() },
) { index, item ->
MarketsListItem(
modifier = Modifier.conditionalCompose(
condition = isRedesignEnabled,
modifier = {
roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
radius = TangemTheme.dimens2.x5,
)
},
),
model = item,
onClick = { state.onItemClick(item) },
)
@ -144,8 +160,8 @@ private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifi
Box(
modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
horizontal = 16.dp,
vertical = 12.dp,
)
.fillMaxWidth(),
contentAlignment = Alignment.Center,

View file

@ -0,0 +1,176 @@
package com.tangem.features.feed.ui.market.list.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.ds.button.PrimaryInverseTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByMenuUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import dev.chrisbanes.haze.HazeState
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.ds.button.TangemButtonIconPosition as RedesignTangemButtonIconPosition
@Suppress("LongParameterList")
@Composable
internal fun Options(
sortMenuUM: SortByMenuUM,
sortByTypeUM: SortByTypeUM,
trendInterval: MarketsListUM.TrendInterval,
hazeState: HazeState,
onSortByClick: () -> Unit,
onIntervalClick: (MarketsListUM.TrendInterval) -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalRedesignEnabled.current) {
OptionsV2(
sortMenuUM = sortMenuUM,
trendInterval = trendInterval,
onIntervalClick = onIntervalClick,
modifier = modifier,
hazeState = hazeState,
)
} else {
OptionsV1(
sortByTypeUM = sortByTypeUM,
trendInterval = trendInterval,
onSortByClick = onSortByClick,
onIntervalClick = onIntervalClick,
modifier = modifier,
)
}
}
@Composable
private fun OptionsV1(
sortByTypeUM: SortByTypeUM,
trendInterval: MarketsListUM.TrendInterval,
onSortByClick: () -> Unit,
onIntervalClick: (MarketsListUM.TrendInterval) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.height(IntrinsicSize.Max)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
SecondarySmallButton(
config = SmallButtonConfig(
text = sortByTypeUM.text,
onClick = onSortByClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
),
)
SegmentedButtons(
config = persistentListOf(
MarketsListUM.TrendInterval.H24,
MarketsListUM.TrendInterval.D7,
MarketsListUM.TrendInterval.M1,
),
color = TangemTheme.colors.button.secondary,
initialSelectedItem = trendInterval,
onClick = onIntervalClick,
modifier = Modifier
.width(160.dp)
.fillMaxHeight(),
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.text.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
}
}
@Composable
private fun OptionsV2(
sortMenuUM: SortByMenuUM,
trendInterval: MarketsListUM.TrendInterval,
hazeState: HazeState,
onIntervalClick: (MarketsListUM.TrendInterval) -> Unit,
modifier: Modifier = Modifier,
) {
var isShowDropdownMenu by rememberSaveable { mutableStateOf(false) }
val segmentItems = remember {
persistentListOf(
TangemSegmentUM(
id = MarketsListUM.TrendInterval.H24.name,
title = MarketsListUM.TrendInterval.H24.text,
),
TangemSegmentUM(
id = MarketsListUM.TrendInterval.D7.name,
title = MarketsListUM.TrendInterval.D7.text,
),
TangemSegmentUM(
id = MarketsListUM.TrendInterval.M1.name,
title = MarketsListUM.TrendInterval.M1.text,
),
)
}
Row(
modifier = modifier
.height(IntrinsicSize.Max)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PrimaryInverseTangemButton(
onClick = {
isShowDropdownMenu = true
},
iconPosition = RedesignTangemButtonIconPosition.End,
iconRes = R.drawable.ic_chewron_down_20,
text = sortMenuUM.selectedOption.text,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
)
TangemSegmentedPicker(
items = segmentItems,
initialSelectedItem = segmentItems.firstOrNull { it.id == trendInterval.name },
isFixed = false,
isAltSurface = true,
minSegmentWidth = 54.dp,
onClick = { segment -> onIntervalClick(MarketsListUM.TrendInterval.valueOf(segment.id)) },
)
}
SortByMenu(
sortMenuUM = sortMenuUM,
showDropdownMenu = isShowDropdownMenu,
onDropdownDismiss = { isShowDropdownMenu = false },
modifier = Modifier.hazeEffectTangem(hazeState) {
blurRadius = 10.dp
},
)
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.feed.ui.market.list.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.ds.contextmenu.TangemContextMenu
import com.tangem.core.ui.ds.contextmenu.TangemContextMenuCheckboxItem
import com.tangem.features.feed.model.market.list.state.SortByMenuUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
@Composable
internal fun SortByMenu(
sortMenuUM: SortByMenuUM,
showDropdownMenu: Boolean,
onDropdownDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemContextMenu(
expanded = showDropdownMenu,
onDismissRequest = onDropdownDismiss,
offset = DpOffset.Zero,
modifier = modifier,
) {
SortByTypeUM.entries.fastForEach { sortType ->
TangemContextMenuCheckboxItem(
title = sortType.text,
isChecked = sortMenuUM.selectedOption == sortType,
onClick = {
sortMenuUM.onOptionClicked(sortType)
onDropdownDismiss()
},
)
}
}
}

View file

@ -13,11 +13,9 @@ 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.UnableToLoadData
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.pager.PagerIndicator
import com.tangem.core.ui.ds.TangemPagerIndicator
import com.tangem.core.ui.ds.TangemPagerIndicatorColors
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.*
import com.tangem.features.feed.ui.news.details.components.ArticleDetail
import com.tangem.features.feed.ui.news.details.components.NewsDetailsPlaceholder
@ -26,7 +24,7 @@ import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory
import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM
@Composable
internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modifier) {
internal fun NewsDetailsContent(state: NewsDetailsUM, contentPadding: PaddingValues, modifier: Modifier = Modifier) {
val background = LocalMainBottomSheetColor.current.value
AnimatedContent(
targetState = state.articlesStateUM,
@ -34,15 +32,16 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
) { animatedState ->
when (animatedState) {
ArticlesStateUM.Content -> {
Content(state = state, background = background)
Content(state = state, background = background, contentPadding = contentPadding)
}
ArticlesStateUM.Loading -> {
NewsDetailsPlaceholder(background = background)
NewsDetailsPlaceholder(background = background, contentPadding = contentPadding)
}
is ArticlesStateUM.LoadingError -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = contentPadding.calculateTopPadding())
.background(background),
contentAlignment = Alignment.Center,
) {
@ -59,7 +58,7 @@ internal fun NewsDetailsContent(state: NewsDetailsUM, modifier: Modifier = Modif
}
@Composable
private fun Content(state: NewsDetailsUM, background: Color) {
private fun Content(contentPadding: PaddingValues, state: NewsDetailsUM, background: Color) {
val isRedesignEnabled = LocalRedesignEnabled.current
val pagerState = rememberPagerState(
initialPage = state.selectedArticleIndex,
@ -84,12 +83,6 @@ private fun Content(state: NewsDetailsUM, background: Color) {
Column(
modifier = Modifier
.fillMaxSize()
.conditionalCompose(
condition = isRedesignEnabled,
modifier = {
hazeSourceTangem(zIndex = 1f)
},
)
.background(background),
) {
Box(modifier = Modifier.fillMaxSize()) {
@ -104,6 +97,7 @@ private fun Content(state: NewsDetailsUM, background: Color) {
modifier = Modifier.fillMaxSize(),
onLikeClick = { state.onLikeClick(article.id) },
relatedTokensUM = state.relatedTokensUM,
contentPadding = contentPadding,
)
}
if (state.articles.size > 1) {
@ -150,6 +144,7 @@ private fun PreviewNewsDetailsContent() {
onBackClick = {},
onArticleIndexChanged = {},
),
contentPadding = PaddingValues(),
)
}
}
@ -174,6 +169,7 @@ private fun PreviewNewsDetailsContentV2() {
onBackClick = {},
onArticleIndexChanged = {},
),
contentPadding = PaddingValues(),
)
}
}

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -28,6 +29,7 @@ import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@ -35,9 +37,11 @@ import com.tangem.features.feed.ui.feed.components.articles.ArticleHeader
import com.tangem.features.feed.ui.news.details.state.ArticleUM
import com.tangem.features.feed.ui.news.details.state.RelatedArticleUM
import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM
import dev.chrisbanes.haze.rememberHazeState
@Composable
internal fun ArticleDetail(
contentPadding: PaddingValues,
article: ArticleUM,
onLikeClick: () -> Unit,
relatedTokensUM: RelatedTokensUM,
@ -45,6 +49,7 @@ internal fun ArticleDetail(
) {
if (LocalRedesignEnabled.current) {
ArticleDetailV2(
contentPadding = contentPadding,
article = article,
onLikeClick = onLikeClick,
relatedTokensUM = relatedTokensUM,
@ -190,6 +195,7 @@ private fun ArticleDetailV1(
@Suppress("LongMethod")
@Composable
internal fun ArticleDetailV2(
contentPadding: PaddingValues,
article: ArticleUM,
onLikeClick: () -> Unit,
relatedTokensUM: RelatedTokensUM,
@ -199,126 +205,127 @@ internal fun ArticleDetailV2(
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
val pagerHeight = 32.dp
val contentPadding = pagerHeight + 56.dp + with(density) {
val bottomPadding = pagerHeight + 56.dp + with(density) {
WindowInsets.navigationBars.getBottom(this).div(this.density)
}.dp
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = 0f)
.background(background),
contentPadding = PaddingValues(bottom = contentPadding),
) {
item("content") {
ArticleHeader(
title = article.title,
createdAt = article.createdAt.resolveReference(),
score = article.score,
tags = article.tags,
isTrending = article.isTrending,
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 16.dp),
)
if (article.shortContent.isNotEmpty()) {
QuickRecap(
content = article.shortContent,
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = -1f)
.background(background),
contentPadding = PaddingValues(bottom = bottomPadding, top = contentPadding.calculateTopPadding()),
) {
item("content") {
ArticleHeader(
title = article.title,
createdAt = article.createdAt.resolveReference(),
score = article.score,
tags = article.tags,
isTrending = article.isTrending,
modifier = Modifier
.padding(top = 32.dp)
.padding(top = 16.dp)
.padding(horizontal = 16.dp),
)
}
Text(
text = article.content,
style = TangemTheme.typography2.bodyRegular16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier
.padding(top = 12.dp)
.padding(horizontal = 16.dp),
)
SpacerH(24.dp)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 24.dp),
color = TangemTheme.colors2.border.neutral.primary,
)
SpacerH(20.dp)
SecondaryTangemButton(
modifier = Modifier.padding(horizontal = 24.dp),
text = resourceReference(R.string.news_like),
size = com.tangem.core.ui.ds.button.TangemButtonSize.X9,
iconRes = if (article.isLiked) {
R.drawable.ic_like_20
} else {
R.drawable.ic_heart_20
},
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLikeClick()
},
shape = TangemButtonShape.Rounded,
)
RelatedTokensBlock(
relatedTokensUM = relatedTokensUM,
onItemClick = when (relatedTokensUM) {
is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick
else -> null
},
modifier = Modifier.padding(horizontal = 16.dp),
)
if (article.relatedArticles.isNotEmpty()) {
SpacerH(24.dp)
Row(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResourceSafe(R.string.news_sources),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
if (article.shortContent.isNotEmpty()) {
QuickRecap(
content = article.shortContent,
modifier = Modifier
.padding(top = 32.dp)
.padding(horizontal = 16.dp),
)
}
}
}
if (article.relatedArticles.isNotEmpty()) {
item("relatedArticles") {
LazyRow(
Text(
text = article.content,
style = TangemTheme.typography2.bodyRegular16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier
.padding(vertical = 12.dp),
state = rememberLazyListState(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = article.relatedArticles,
key = RelatedArticleUM::id,
) { article ->
RelatedNewsItem(
relatedArticle = article,
modifier = Modifier.fillParentMaxHeight(),
.padding(top = 12.dp)
.padding(horizontal = 16.dp),
)
SpacerH(24.dp)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 24.dp),
color = TangemTheme.colors2.border.neutral.primary,
)
SpacerH(20.dp)
SecondaryTangemButton(
modifier = Modifier.padding(horizontal = 24.dp),
text = resourceReference(R.string.news_like),
size = com.tangem.core.ui.ds.button.TangemButtonSize.X9,
iconRes = if (article.isLiked) {
R.drawable.ic_like_20
} else {
R.drawable.ic_heart_20
},
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLikeClick()
},
shape = TangemButtonShape.Rounded,
)
RelatedTokensBlock(
relatedTokensUM = relatedTokensUM,
onItemClick = when (relatedTokensUM) {
is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick
else -> null
},
modifier = Modifier.padding(horizontal = 16.dp),
)
if (article.relatedArticles.isNotEmpty()) {
SpacerH(24.dp)
Row(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResourceSafe(R.string.news_sources),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
)
}
}
}
}
}
BottomFadeWithBlur(
modifier = Modifier
.align(Alignment.BottomCenter)
.height(80.dp)
.fillMaxWidth(),
backgroundColor = background,
)
if (article.relatedArticles.isNotEmpty()) {
item("relatedArticles") {
LazyRow(
modifier = Modifier.padding(vertical = 12.dp),
state = rememberLazyListState(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items = article.relatedArticles,
key = RelatedArticleUM::id,
) { article ->
RelatedNewsItem(
relatedArticle = article,
modifier = Modifier.fillParentMaxHeight(),
)
}
}
}
}
}
BottomFadeWithBlur(
modifier = Modifier
.align(Alignment.BottomCenter)
.height(80.dp)
.fillMaxWidth(),
backgroundColor = background,
)
}
}
}

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