Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-25 16:39:35 +03:00
commit bfd903b43c
595 changed files with 12755 additions and 19837 deletions

View file

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

View file

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

View file

@ -25,7 +25,6 @@ import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.account.derivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.account.analytics.AccountSettingsAnalyticEvents
@ -73,7 +72,7 @@ internal class AccountCreateEditModel @Inject constructor(
when (params) {
is AccountCreateEditComponent.Params.Create -> updateDerivationInfo(userWalletId = params.userWalletId)
is AccountCreateEditComponent.Params.Edit -> {
val derivationIndex = params.account.derivationIndex?.value
val derivationIndex = params.account.derivationIndex.value
val event = AccountSettingsAnalyticEvents.AccountEditScreenOpened(derivationIndex)
analyticsEventHandler.send(event)
}
@ -170,7 +169,7 @@ internal class AccountCreateEditModel @Inject constructor(
val icon = CryptoPortfolioIconConverter.convertBack(state.account.portfolioIcon)
val isNewName = name != params.account.accountName
val isNewIcon = icon != params.account.portfolioIcon
val derivationIndex = params.account.derivationIndex?.value
val derivationIndex = params.account.derivationIndex.value
analyticsEventHandler.send(AccountSettingsAnalyticEvents.ButtonSave(name, icon, derivationIndex))
uiState.value = uiState.value.toggleProgress(showProgress = true)
@ -182,7 +181,7 @@ internal class AccountCreateEditModel @Inject constructor(
uiState.value = uiState.value.toggleProgress(showProgress = false)
result
.onLeft { error -> handleEditAccountError(error, params.account.derivationIndex?.value) }
.onLeft { error -> handleEditAccountError(error, params.account.derivationIndex.value) }
.onRight {
showMessage(R.string.account_edit_success_message)
router.pop()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,33 @@
package com.tangem.features.approval.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalComponent
import com.tangem.features.approval.impl.model.GiveApprovalModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
internal interface GiveApprovalFeatureModule {
@Binds
@Singleton
fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory
}
@Module
@InstallIn(ModelComponent::class)
internal interface GiveApprovalModelModule {
@Binds
@IntoMap
@ClassKey(GiveApprovalModel::class)
fun bindModel(model: GiveApprovalModel): Model
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,6 @@ package com.tangem.features.feed.ui.earn
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.Icon
@ -14,21 +13,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
@ -39,6 +38,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.earn.components.EarnItemPlaceholder
import com.tangem.features.feed.ui.earn.components.EarnListItem
import com.tangem.features.feed.ui.earn.components.MostlyUsedCard
import com.tangem.features.feed.ui.earn.components.MostlyUsedPlaceholder
import com.tangem.features.feed.ui.earn.state.*
import kotlinx.collections.immutable.persistentListOf
@ -91,12 +91,7 @@ internal fun EarnContent(state: EarnUM, modifier: Modifier = Modifier) {
SpacerH(12.dp)
BestOpportunitiesFilters(
state = state.bestOpportunities,
selectedNetworkFilterText = when (state.selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks)
is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks)
is EarnFilterNetworkUM.Network -> TextReference.Str(state.selectedNetworkFilter.text)
},
selectedTypeFilterText = state.selectedTypeFilterText,
earnFilterUM = state.earnFilterUM,
onNetworkFilterClick = state.onNetworkFilterClick,
onTypeFilterClick = state.onTypeFilterClick,
)
@ -151,10 +146,12 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 16.dp,
vertical = 12.dp,
),
.padding(horizontal = 16.dp, vertical = 12.dp)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(vertical = 32.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = st.onRetryClicked)
@ -164,71 +161,17 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
}
}
@Composable
private fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(148.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(onClick = onClick)
.padding(12.dp),
) {
CurrencyIcon(
modifier = Modifier.size(32.dp),
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = 12.dp,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors.text.accent,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun BestOpportunitiesFilters(
state: EarnBestOpportunitiesUM,
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
earnFilterUM: EarnFilterUM,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
) {
when (state) {
is EarnBestOpportunitiesUM.Loading -> FilterButtonsShimmer()
else -> FilterButtons(
selectedNetworkFilterText = selectedNetworkFilterText,
selectedTypeFilterText = selectedTypeFilterText,
isEnabled = state is EarnBestOpportunitiesUM.Content || state is EarnBestOpportunitiesUM.EmptyFiltered,
earnFilterUM = earnFilterUM,
onNetworkFilterClick = onNetworkFilterClick,
onTypeFilterClick = onTypeFilterClick,
)
@ -307,9 +250,7 @@ private fun LazyListScope.bestOpportunitiesItems(state: EarnBestOpportunitiesUM)
@Composable
private fun FilterButtons(
selectedNetworkFilterText: TextReference,
selectedTypeFilterText: TextReference,
isEnabled: Boolean,
earnFilterUM: EarnFilterUM,
onNetworkFilterClick: () -> Unit,
onTypeFilterClick: () -> Unit,
modifier: Modifier = Modifier,
@ -319,10 +260,14 @@ private fun FilterButtons(
) {
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedNetworkFilterText,
text = when (earnFilterUM.selectedNetworkFilter) {
is EarnFilterNetworkUM.AllNetworks -> TextReference.Res(R.string.earn_filter_all_networks)
is EarnFilterNetworkUM.MyNetworks -> TextReference.Res(R.string.earn_filter_my_networks)
is EarnFilterNetworkUM.Network -> TextReference.Str(earnFilterUM.selectedNetworkFilter.text)
},
onClick = onNetworkFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
isEnabled = earnFilterUM.isNetworkFilterEnabled,
),
)
@ -330,10 +275,10 @@ private fun FilterButtons(
SecondarySmallButton(
config = SmallButtonConfig(
text = selectedTypeFilterText,
text = earnFilterUM.selectedTypeFilter.text,
onClick = onTypeFilterClick,
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_chevron_24),
isEnabled = isEnabled,
isEnabled = earnFilterUM.isTypeFilterEnabled,
),
)
}
@ -496,7 +441,7 @@ private fun EarnContentLoadingPreview() {
) {
EarnContent(
state = previewEarnUM(
mostlyUsed = EarnListUM.Loading,
mostlyUsed = EarnListUM.Error(onRetryClicked = {}),
bestOpportunities = EarnBestOpportunitiesUM.Loading,
),
)
@ -588,8 +533,12 @@ private fun previewEarnUM(
): EarnUM = EarnUM(
mostlyUsed = mostlyUsed,
bestOpportunities = bestOpportunities,
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
earnFilterUM = EarnFilterUM(
selectedTypeFilter = EarnFilterTypeUM.All,
selectedNetworkFilter = EarnFilterNetworkUM.AllNetworks(isSelected = true),
isTypeFilterEnabled = true,
isNetworkFilterEnabled = true,
),
onBackClick = {},
onNetworkFilterClick = {},
onTypeFilterClick = {},

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
@ -33,10 +34,13 @@ internal fun EarnBlock(onSeeAllClick: () -> Unit, earnListUM: EarnListUM?, modif
text = stringResourceSafe(R.string.markets_earn_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
onSeeAllClick = onSeeAllClick,
isLoading = earnListUM is EarnListUM.Loading,
shouldShowSeeAll = earnListUM is EarnListUM.Content,
)
SpacerH(12.dp)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.runtime.Composable
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.features.feed.ui.feed.state.NewsSliderConfig
@Composable
internal fun NewsSlider(newsSliderConfig: NewsSliderConfig) {
val isRedesignEnabled = LocalRedesignEnabled.current
if (isRedesignEnabled) {
NewsSliderV2(newsSliderConfig)
} else {
NewsSliderV1(newsSliderConfig)
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.unit.dp
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ShowMoreArticlesCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.state.NewsSliderConfig
@Composable
internal fun NewsSliderV1(newsSliderConfig: NewsSliderConfig) {
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
state = rememberLazyListState(),
) {
itemsIndexed(
items = newsSliderConfig.content,
key = { index, _ -> index },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = if (index == FOURTH_ITEM_INDEX) {
Modifier.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderScroll,
)
} else {
Modifier
}
ArticleCard(
articleConfigUM = article,
onArticleClick = { newsSliderConfig.callbacks.onArticleClick(article.id) },
modifier = articleModifier
.heightIn(min = 164.dp)
.width(216.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
)
}
if (newsSliderConfig.shouldShowSeeAllNewsItem) {
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.width(216.dp)
.heightIn(min = 164.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderEndReached,
),
onClick = newsSliderConfig.callbacks.onOpenAllNews,
)
}
}
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.features.feed.ui.feed.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
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.ShowMoreArticlesCard
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.ui.feed.state.NewsSliderConfig
private val dividerSpacerWidth = 20.dp
@Suppress("MagicNumber", "LongMethod")
@Composable
internal fun NewsSliderV2(newsSliderConfig: NewsSliderConfig) {
val density = LocalDensity.current
val dividerWidthPx = with(density) { 1.dp.roundToPx() }
val spacerWidthPx = with(density) { dividerSpacerWidth.roundToPx() }
LazyRow(
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 16.dp),
state = rememberLazyListState(),
) {
itemsIndexed(
items = newsSliderConfig.content,
key = { index, _ -> index },
contentType = { _, _ -> "article" },
) { index, article ->
val articleModifier = Modifier.conditional(
condition = index == FOURTH_ITEM_INDEX,
modifier = {
onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderScroll,
)
},
)
val shouldShowDivider = newsSliderConfig.shouldShowSeeAllNewsItem ||
index < newsSliderConfig.content.size - 1
// have to use layout cause LazyRow has not fixed height and divider can not be measured
Layout(
modifier = Modifier,
content = {
ArticleCard(
articleConfigUM = article,
onArticleClick = { newsSliderConfig.callbacks.onArticleClick(article.id) },
modifier = articleModifier
.fillMaxHeight()
.width(220.dp),
)
Spacer(modifier = Modifier.width(dividerSpacerWidth))
Box(
modifier = Modifier
.width(1.dp)
.background(TangemTheme.colors2.border.neutral.secondary),
)
Spacer(modifier = Modifier.width(dividerSpacerWidth))
},
) { measurables, constraints ->
val cardPlaceable = measurables[0].measure(constraints)
val height = cardPlaceable.height
if (shouldShowDivider) {
val leftSpacer = measurables[1].measure(Constraints.fixed(spacerWidthPx, height))
val divider = measurables[2].measure(Constraints.fixed(dividerWidthPx, height))
val rightSpacer = measurables[3].measure(Constraints.fixed(spacerWidthPx, height))
val totalWidth = cardPlaceable.width + leftSpacer.width + divider.width + rightSpacer.width
layout(totalWidth, height) {
cardPlaceable.place(0, 0)
leftSpacer.place(cardPlaceable.width, 0)
divider.place(cardPlaceable.width + leftSpacer.width, 0)
rightSpacer.place(cardPlaceable.width + leftSpacer.width + divider.width, 0)
}
} else {
layout(cardPlaceable.width, height) {
cardPlaceable.place(0, 0)
}
}
}
}
if (newsSliderConfig.shouldShowSeeAllNewsItem) {
item(contentType = "show_more") {
ShowMoreArticlesCard(
modifier = Modifier
.fillMaxHeight()
.width(216.dp)
.onFirstVisible(
minFractionVisible = 0.5f,
callback = newsSliderConfig.callbacks.onSliderEndReached,
),
onClick = newsSliderConfig.callbacks.onOpenAllNews,
)
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,64 @@
package com.tangem.features.feed.ui.feed.components.articles
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.res.TangemTheme
@Composable
fun TrendingLoadingArticle(modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RectangleShimmer(modifier = Modifier.size(width = 96.dp, height = 24.dp), radius = 8.dp)
SpacerH(12.dp)
RectangleShimmer(modifier = Modifier.size(width = 285.dp, height = 18.dp), radius = 4.dp)
SpacerH(6.dp)
RectangleShimmer(modifier = Modifier.size(width = 190.dp, height = 18.dp), radius = 4.dp)
SpacerH(14.dp)
RectangleShimmer(modifier = Modifier.size(width = 110.dp, height = 18.dp), radius = 4.dp)
SpacerH(32.dp)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 24.dp), radius = 8.dp)
RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 24.dp), radius = 8.dp)
RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 24.dp), radius = 8.dp)
}
}
}
}
@Composable
fun DefaultLoadingArticle(modifier: Modifier = Modifier) {
BlockCard(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(modifier = Modifier.padding(12.dp)) {
RectangleShimmer(modifier = Modifier.size(width = 110.dp, height = 16.dp), radius = 4.dp)
SpacerH(12.dp)
RectangleShimmer(modifier = Modifier.size(width = 142.dp, height = 18.dp), radius = 4.dp)
SpacerH(6.dp)
RectangleShimmer(modifier = Modifier.size(width = 176.dp, height = 18.dp), radius = 4.dp)
SpacerH(6.dp)
RectangleShimmer(modifier = Modifier.size(width = 120.dp, height = 18.dp), radius = 4.dp)
SpacerH(16.dp)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RectangleShimmer(modifier = Modifier.size(width = 72.dp, height = 24.dp), radius = 8.dp)
RectangleShimmer(modifier = Modifier.size(width = 72.dp, height = 24.dp), radius = 8.dp)
}
}
}
}

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ package com.tangem.features.feed.ui.feed.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.earn.state.EarnListUM

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.chip.Chip
import com.tangem.core.ui.components.chip.entity.ChipUM

View file

@ -16,9 +16,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.common.ui.news.DefaultLoadingArticle
import com.tangem.features.feed.ui.feed.components.articles.ArticleCard
import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM
import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.block.TangemBlockCardColors

View file

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

View file

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

View file

@ -1,40 +0,0 @@
package com.tangem.features.markets.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import kotlinx.serialization.Serializable
@Stable
interface MarketsTokenDetailsComponent : ComposableContentComponent {
@Serializable
data class Params(
val token: TokenMarketParams,
val appCurrency: AppCurrency,
val shouldShowPortfolio: Boolean,
val analyticsParams: AnalyticsParams?,
)
@Serializable
data class AnalyticsParams(
val blockchain: String?,
val source: String,
)
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface Factory : ComponentFactory<Params, MarketsTokenDetailsComponent>
}

View file

@ -1,24 +0,0 @@
package com.tangem.features.markets.entry
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
@Stable
interface MarketsEntryComponent {
@Composable
fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
)
interface Factory {
fun create(context: AppComponentContext): MarketsEntryComponent
}
}

View file

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

View file

@ -1,164 +0,0 @@
package com.tangem.features.markets.details.impl
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
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.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.MarketsTokenDetailsComponent.Params
import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent
import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Stable
internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Params,
analyticsEventHandler: AnalyticsEventHandler,
private val accountsFeatureToggles: AccountsFeatureToggles,
portfolioComponentFactory: MarketsPortfolioComponent.Factory,
) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent {
// applying l2 compatibility
private val updatedParams = params.copy(
token = params.token.copy(
id = CryptoCurrency.RawID(getTokenIdIfL2Network(params.token.id.value)),
),
)
private val analyticsParams = params.analyticsParams
private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams)
private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.shouldShowPortfolio) {
portfolioComponentFactory.create(
context = child("my_portfolio"),
params = MarketsPortfolioComponent.Params(
updatedParams.token,
analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) },
),
)
} else {
null
}
init {
componentScope.launch {
model.networksState.collectLatest { networksState ->
when (networksState) {
is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(
networksState.networks,
)
TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable()
else -> {}
}
}
}
// === Analytics ===
if (analyticsParams != null) {
analyticsEventHandler.send(
MarketDetailsAnalyticsEvent.EventBuilder(
token = params.token,
).screenOpened(
blockchain = analyticsParams.blockchain,
source = analyticsParams.source,
),
)
}
}
@Composable
override fun BottomSheetContent(
bottomSheetState: State<BottomSheetState>,
onHeaderSizeChange: (Dp) -> Unit,
modifier: Modifier,
) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
val bsState by bottomSheetState
LaunchedEffect(bsState) {
model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED
}
BackHandler(enabled = bsState == BottomSheetState.EXPANDED) {
navigateBack()
}
MarketsTokenDetailsContent(
modifier = modifier,
backgroundColor = LocalMainBottomSheetColor.current.value,
addTopBarStatusBarPadding = false,
state = state,
onBackClick = ::navigateBack,
backButtonEnabled = bsState == BottomSheetState.EXPANDED,
onHeaderSizeChange = onHeaderSizeChange,
isAccountEnabled = accountsFeatureToggles.isFeatureEnabled,
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
}
},
)
}
@Composable
override fun Content(modifier: Modifier) {
LifecycleStartEffect(Unit) {
model.isVisibleOnScreen.value = true
onStopOrDispose {
model.isVisibleOnScreen.value = false
}
}
val state by model.state.collectAsStateWithLifecycle()
MarketsTokenDetailsContent(
modifier = modifier,
backgroundColor = TangemTheme.colors.background.tertiary,
addTopBarStatusBarPadding = true,
state = state,
onBackClick = ::navigateBack,
backButtonEnabled = true,
onHeaderSizeChange = {},
isAccountEnabled = accountsFeatureToggles.isFeatureEnabled,
portfolioBlock = portfolioComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
}
},
)
}
private fun navigateBack() = router.pop()
@AssistedFactory
interface Factory : MarketsTokenDetailsComponent.Factory {
override fun create(context: AppComponentContext, params: Params): DefaultMarketsTokenDetailsComponent
}
}

View file

@ -1,84 +0,0 @@
package com.tangem.features.markets.details.impl.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketParams
internal class MarketDetailsAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) {
data class EventBuilder(
val token: TokenMarketParams,
) {
fun screenOpened(blockchain: String?, source: String) = MarketDetailsAnalyticsEvent(
event = "Token Chart Screen Opened",
params = buildMap {
put("Token", token.symbol)
blockchain?.let { put("blockchain", it) }
put("Source", source)
},
)
fun intervalChanged(intervalType: IntervalType, interval: PriceChangeInterval) = MarketDetailsAnalyticsEvent(
event = "Button - Period",
params = mapOf(
"Token" to token.symbol,
"Period" to interval.toAnalyticsString(),
"Source" to intervalType.source,
),
)
fun readMoreClicked() = MarketDetailsAnalyticsEvent(
event = "Button - Read More",
params = mapOf(
"Token" to token.symbol,
),
)
fun linkClicked(linkTitle: String) = MarketDetailsAnalyticsEvent(
event = "Button - Links",
params = mapOf(
"Token" to token.symbol,
"Link" to linkTitle,
),
)
fun exchangesScreenOpened() = MarketDetailsAnalyticsEvent(
event = "Exchanges Screen Opened",
params = mapOf(
"Token" to token.symbol,
),
)
fun securityScoreOpened() = MarketDetailsAnalyticsEvent(
event = "Security Score Info",
params = mapOf("Token" to token.symbol),
)
fun securityScoreProviderClicked(provider: String) = MarketDetailsAnalyticsEvent(
event = "Security Score Provider Clicked",
params = mapOf(
"Token" to token.symbol,
"Provider" to provider,
),
)
}
enum class IntervalType(val source: String) {
Chart("Chart"),
PricePerformance("Price"),
Insights("Insights"),
}
}
private fun PriceChangeInterval.toAnalyticsString() = when (this) {
PriceChangeInterval.H24 -> "24h"
PriceChangeInterval.WEEK -> "7d"
PriceChangeInterval.MONTH -> "1m"
PriceChangeInterval.MONTH3 -> "3m"
PriceChangeInterval.MONTH6 -> "6m"
PriceChangeInterval.YEAR -> "1y"
PriceChangeInterval.ALL_TIME -> "All"
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.markets.details.impl.di
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.DefaultMarketsTokenDetailsComponent
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
@Binds
@Singleton
fun bindMarketsTokenDetailsComponent(
factory: DefaultMarketsTokenDetailsComponent.Factory,
): MarketsTokenDetailsComponent.Factory
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.markets.details.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface ModelModule {
@Binds
@IntoMap
@ClassKey(MarketsTokenDetailsModel::class)
fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model
}

View file

@ -1,645 +0,0 @@
package com.tangem.features.markets.details.impl.model
import androidx.compose.runtime.Stable
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.common.ui.charts.state.sorted
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.markets.*
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.markets.details.impl.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter
import com.tangem.features.markets.details.impl.model.converters.ExchangeItemStateConverter
import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter
import com.tangem.features.markets.details.impl.model.formatter.*
import com.tangem.features.markets.details.impl.model.state.QuotesStateUpdater
import com.tangem.features.markets.details.impl.model.state.TokenNetworksState
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import com.tangem.lib.crypto.BlockchainUtils
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.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.joda.time.DateTime
import java.math.BigDecimal
import java.util.Locale
import javax.inject.Inject
@Suppress("LargeClass", "LongParameterList")
@Stable
@ModelScoped
internal class MarketsTokenDetailsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase,
private val getTokenExchangesUseCase: GetTokenExchangesUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserCountryUseCase: GetUserCountryUseCase,
private val getUserWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val quotesJob = JobHolder()
private var userCountry: UserCountry? = null
private val params = paramsContainer.require<MarketsTokenDetailsComponent.Params>()
private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token)
private val currentAppCurrency = getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = params.appCurrency,
)
private val infoConverter = TokenMarketInfoConverter(
appCurrency = Provider { currentAppCurrency.value },
onInfoClick = { showBottomSheet(it) },
onListedOnClick = ::onListedOnClick,
onLinkClick = { link ->
urlOpener.openUrl(link.url)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.linkClicked(linkTitle = link.title))
},
onSecurityScoreInfoClick = { content ->
showBottomSheet(content)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.securityScoreOpened())
},
onSecurityScoreProviderLinkClick = { provider ->
provider.urlData?.fullUrl?.let { url ->
urlOpener.openUrl(url)
}
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.securityScoreProviderClicked(provider.name))
},
// === Analytics ===
onPricePerformanceIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance,
interval = interval,
),
)
},
onInsightsIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.Insights,
interval = interval,
),
)
},
needApplyFCARestrictions = Provider {
userCountry.needApplyFCARestrictions()
},
// ==================
)
private val descriptionConverter = DescriptionConverter(
onReadModeClicked = { content ->
showBottomSheet(content)
// === Analytics ===
analyticsEventHandler.send(analyticsEventBuilder.readMoreClicked())
},
needApplyFCARestrictions = Provider {
userCountry.needApplyFCARestrictions()
},
onGeneratedAINotificationClick = {
modelScope.launch {
sendFeedbackEmailUseCase(
type = FeedbackEmailType.CurrencyDescriptionError(
currencyId = params.token.id.value,
currencyName = params.token.name,
),
)
}
},
)
private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) {
chartData = MarketChartData.NoData.Loading
updateLook { currentLook ->
val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType()
currentLook.copy(
type = percentChangeType.toChartType(),
xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24),
yAxisFormatter = { value ->
value.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
}
},
)
}
}
private val currentQuotes = MutableStateFlow(
TokenQuotes(
currentPrice = params.token.tokenQuotes.currentPrice,
h24ChangePercent = params.token.tokenQuotes.h24Percent,
weekChangePercent = params.token.tokenQuotes.weekPercent,
monthChangePercent = params.token.tokenQuotes.monthPercent,
m3ChangePercent = null,
m6ChangePercent = null,
yearChangePercent = null,
allTimeChangePercent = null,
),
)
private val currentTokenInfo = MutableStateFlow<TokenMarketInfo?>(null)
private val lastUpdatedTimestamp = MutableStateFlow(DateTime.now().millis)
val isVisibleOnScreen = MutableStateFlow(false)
val networksState = MutableStateFlow<TokenNetworksState>(TokenNetworksState.Loading)
val state = MutableStateFlow(
MarketsTokenDetailsUM(
tokenName = params.token.name,
priceText = params.token.tokenQuotes.currentPrice.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,
fiatCurrencySymbol = currentAppCurrency.value.symbol,
).price()
},
dateTimeText = resourceReference(R.string.common_today),
priceChangePercentText = params.token.tokenQuotes.h24Percent?.format { percent() },
priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(),
iconUrl = params.token.imageUrl,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = chartDataProducer,
onLoadRetryClick = ::onLoadRetryClicked,
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = ::onMarkerPointSelected,
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = ::onSelectedIntervalChange,
isMarkerSet = false,
body = MarketsTokenDetailsUM.Body.Loading,
triggerPriceChange = consumedEvent(),
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
),
)
private val quotesStateUpdater = QuotesStateUpdater(
currentAppCurrency = Provider { currentAppCurrency.value },
state = state,
currentQuotes = currentQuotes,
lastUpdatedTimestamp = lastUpdatedTimestamp,
currentTokenInfo = currentTokenInfo,
onPricePerformanceIntervalChanged = { interval ->
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.PricePerformance,
interval = interval,
),
)
},
)
private val loadChartJobHolder = JobHolder()
init {
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
// reload screen if currency changed
modelScope.launch {
currentAppCurrency
.filter { it != params.appCurrency }
.collectLatest { _ ->
initialLoad()
}
}
initialLoad()
}
private fun initialLoad() {
loadInfo()
loadChart(state.value.selectedInterval)
modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS)
}
private fun loadQuotes() {
modelScope.launch {
val result = getTokenFullQuotesUseCase(
tokenId = params.token.id,
appCurrency = currentAppCurrency.value,
tokenSymbol = params.token.symbol,
)
result.onRight { res ->
updateQuotes(res)
}
}
}
private fun loadChart(interval: PriceChangeInterval) {
modelScope.launch {
state.update { currentState ->
currentState.copy(
chartState = currentState.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
),
)
}
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.NoData.Loading
}
val chart = getTokenPriceChartUseCase.invoke(
appCurrency = currentAppCurrency.value,
interval = interval,
tokenId = params.token.id,
tokenSymbol = params.token.symbol,
preview = false,
)
state.update { currentState ->
currentState.copy(
selectedInterval = interval,
chartState = currentState.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
),
)
}
chart
.onRight { updateTokenChart(it) }
.onLeft {
state.update { currentState ->
currentState.copy(
chartState = currentState.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.ERROR,
),
body = if (currentState.body is MarketsTokenDetailsUM.Body.Error) {
MarketsTokenDetailsUM.Body.Nothing
} else {
currentState.body
},
)
}
}
}.saveIn(loadChartJobHolder)
}
private suspend fun updateTokenChart(tokenChart: TokenChart) {
val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval)
chartDataProducer.runTransactionSuspend {
chartData = MarketChartData.Data(
y = tokenChart.priceY.toImmutableList(),
x = tokenChart.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted()
updateLook { currentLook ->
currentLook.copy(
xAxisFormatter = xAxisFormatter,
type = state.value.priceChangeType.toChartType(),
)
}
}
state.update { currentState ->
currentState.copy(
chartState = currentState.chartState.copy(
status = MarketsTokenDetailsUM.ChartState.Status.DATA,
),
body = if (currentState.body is MarketsTokenDetailsUM.Body.Nothing) {
MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked)
} else {
currentState.body
},
)
}
}
private fun loadInfo() {
state.update { currentState ->
currentState.copy(
body = MarketsTokenDetailsUM.Body.Loading,
)
}
modelScope.launch {
val tokenMarketInfo = getTokenMarketInfoUseCase(
appCurrency = currentAppCurrency.value,
tokenId = params.token.id,
tokenSymbol = params.token.symbol,
)
tokenMarketInfo.fold(
ifRight = { result -> updateInfo(result) },
ifLeft = {
state.update { currentState ->
if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) {
currentState.copy(
body = MarketsTokenDetailsUM.Body.Error(
onLoadRetryClick = ::onLoadRetryClicked,
),
)
} else {
currentState.copy(
body = MarketsTokenDetailsUM.Body.Nothing,
)
}
}
},
)
}
}
private fun updateInfo(newInfo: TokenMarketInfo) {
lastUpdatedTimestamp.value = DateTime.now().millis
currentTokenInfo.value = newInfo
currentQuotes.value = newInfo.quotes
val percent = newInfo.quotes.getPercentByInterval(interval = state.value.selectedInterval)
state.update { currentState ->
currentState.copy(
priceText = newInfo.quotes.currentPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
},
priceChangePercentText = newInfo.quotes.getFormattedPercentByInterval(
interval = currentState.selectedInterval,
),
priceChangeType = percent.percentChangeType(),
body = MarketsTokenDetailsUM.Body.Content(
description = descriptionConverter.convert(newInfo),
infoBlocks = infoConverter.convert(newInfo),
),
)
}
val areAllWalletsHot = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = newInfo.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = areAllWalletsHot,
)
}
networksState.value = if (networks.isNullOrEmpty()) {
TokenNetworksState.NoNetworksAvailable
} else {
TokenNetworksState.NetworksAvailable(networks)
}
chartDataProducer.runTransaction {
updateLook { currentLook ->
currentLook.copy(type = percent.percentChangeType().toChartType())
}
}
}
private suspend fun updateQuotes(newQuotes: TokenQuotes) {
val populatedNewQuotes = currentQuotes.value.populateWith(newQuotes)
quotesStateUpdater.updateQuotes(newQuotes = populatedNewQuotes)
val percent = populatedNewQuotes
.getPercentByInterval(interval = state.value.selectedInterval)
chartDataProducer.runTransaction {
updateLook {
it.copy(type = percent.percentChangeType().toChartType())
}
}
}
private fun onSelectedIntervalChange(interval: PriceChangeInterval) {
if (state.value.selectedInterval == interval) return
// === Analytics ===
analyticsEventHandler.send(
analyticsEventBuilder.intervalChanged(
intervalType = MarketDetailsAnalyticsEvent.IntervalType.Chart,
interval = interval,
),
)
// ==================
val quotes = currentQuotes.value
val priceChangePercent = quotes.getFormattedPercentByInterval(interval)
state.update { currentState ->
currentState.copy(
priceChangePercentText = priceChangePercent,
selectedInterval = interval,
priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType()
?: PriceChangeType.NEUTRAL,
dateTimeText = getDefaultDateTimeString(interval),
)
}
loadChart(interval)
if (priceChangePercent.isEmpty()) {
loadQuotes()
}
}
@Suppress("MagicNumber")
private fun onMarkerPointSelected(markerTimestamp: BigDecimal?, price: BigDecimal?) {
val currentState = state.value
val dateTimeText = markerTimestamp?.let { timestamp ->
MarketsDateTimeFormatters.formatDateByIntervalWithMarker(
interval = currentState.selectedInterval,
markerTimestamp = timestamp,
)
} ?: getDefaultDateTimeString(currentState.selectedInterval)
val priceText = (price ?: currentQuotes.value.currentPrice).format {
fiat(
fiatCurrencySymbol = currentAppCurrency.value.symbol,
fiatCurrencyCode = currentAppCurrency.value.code,
).price()
}
val percent = price?.let { selectedPrice ->
getChangePercentBetween(
previousPrice = selectedPrice,
currentPrice = currentQuotes.value.currentPrice,
)
} ?: currentQuotes.value.getPercentByInterval(currentState.selectedInterval)
val percentText = percent?.format { percent() }.orEmpty()
state.update { stateToUpdate ->
stateToUpdate.copy(
isMarkerSet = markerTimestamp != null,
dateTimeText = dateTimeText,
priceText = priceText,
priceChangePercentText = percentText,
priceChangeType = percent.percentChangeType(),
)
}
chartDataProducer.runTransaction {
updateLook { currentLook ->
currentLook.copy(
type = percent.percentChangeType().toChartType(),
)
}
}
}
private fun showBottomSheet(content: TangemBottomSheetConfigContent) {
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(
isShown = true,
onDismissRequest = ::hideBottomSheet,
content = content,
),
)
}
}
private fun hideBottomSheet() {
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(isShown = false),
)
}
}
private fun onLoadRetryClicked() {
val currentState = state.value
if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.ERROR) {
loadChart(currentState.selectedInterval)
}
if (currentState.body is MarketsTokenDetailsUM.Body.Error ||
currentState.body is MarketsTokenDetailsUM.Body.Nothing
) {
loadInfo()
modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS)
}
}
private fun onListedOnClick(exchangesCount: Int) {
modelScope.launch {
analyticsEventHandler.send(analyticsEventBuilder.exchangesScreenOpened())
showBottomSheet(content = ExchangesBottomSheetContent.Loading(exchangesCount))
val maybeExchanges = getTokenExchangesUseCase(tokenId = params.token.id)
// Delay to show the bottom sheet
delay(timeMillis = 400L)
updateExchangeBSContent(maybeExchanges = maybeExchanges, exchangesCount = exchangesCount)
}
}
private fun updateExchangeBSContent(
maybeExchanges: Either<Throwable, List<TokenMarketExchange>>,
exchangesCount: Int,
) {
val content = maybeExchanges
.fold(
ifLeft = {
ExchangesBottomSheetContent.Error(onRetryClick = { onListedOnClick(exchangesCount) })
},
ifRight = { exchanges ->
ExchangesBottomSheetContent.Content(
exchangeItems = ExchangeItemStateConverter.convertList(exchanges).toImmutableList(),
)
},
)
state.update { stateToUpdate ->
stateToUpdate.copy(
bottomSheetConfig = stateToUpdate.bottomSheetConfig.copy(content = content),
)
}
}
private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) {
launch {
while (true) {
delay(timeMillis)
// Update quotes only when content is visible on the screen
isVisibleOnScreen.first { it }
loadQuotes()
}
}.saveIn(quotesJob)
}
private fun getDefaultDateTimeString(interval: PriceChangeInterval): TextReference {
return MarketsDateTimeFormatters.formatDateByInterval(
interval = interval,
startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval(
interval = interval,
currentTimestamp = lastUpdatedTimestamp.value,
),
)
}
private companion object {
const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L
}
}

View file

@ -1,45 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
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.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@Stable
internal class DescriptionConverter(
private val onReadModeClicked: (InfoBottomSheetContent) -> Unit,
private val onGeneratedAINotificationClick: () -> Unit,
private val needApplyFCARestrictions: Provider<Boolean>,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.Description?> {
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? {
if (needApplyFCARestrictions()) return null
val shortDesc = value.shortDescription ?: return null
return MarketsTokenDetailsUM.Description(
shortDescription = stringReference(shortDesc),
fullDescription = value.fullDescription?.let(::stringReference),
onReadMoreClick = {
onReadModeClicked(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_about_token_title,
wrappedList(
value.name,
),
),
body = stringReference(value.fullDescription.orEmpty()),
generatedAINotificationUM = InfoBottomSheetContent.GeneratedAINotificationUM(
onClick = onGeneratedAINotificationClick,
),
),
)
},
)
}
}

View file

@ -1,68 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
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.markets.TokenMarketExchange
import com.tangem.domain.markets.TokenMarketExchange.TrustScore
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
/**
* Converter from [TokenMarketExchange] to [TokenItemState]
*
[REDACTED_AUTHOR]
*/
internal object ExchangeItemStateConverter : Converter<TokenMarketExchange, TokenItemState> {
override fun convert(value: TokenMarketExchange): TokenItemState {
return TokenItemState.Content(
id = value.id,
iconState = CurrencyIconState.CoinIcon(
url = value.imageUrl,
fallbackResId = R.drawable.ic_alert_24,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = stringReference(value.name)),
fiatAmountState = TokenItemState.FiatAmountState.Content(
text = value.volumeInUsd.format {
fiat(
fiatCurrencyCode = "USD",
fiatCurrencySymbol = "$",
).price()
},
),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = stringReference(value = if (value.isCentralized) "CEX" else "DEX"),
),
subtitle2State = TokenItemState.Subtitle2State.LabelContent(
auditLabelUM = value.trustScore.toAuditLabelUM(),
),
onItemClick = null,
onItemLongClick = null,
)
}
private fun TrustScore.toAuditLabelUM(): AuditLabelUM {
return when (this) {
TrustScore.Risky -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_risky),
type = AuditLabelUM.Type.Prohibition,
)
TrustScore.Caution -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_caution),
type = AuditLabelUM.Type.Warning,
)
TrustScore.Trusted -> AuditLabelUM(
text = resourceReference(id = R.string.markets_token_details_exchange_trust_score_trusted),
type = AuditLabelUM.Type.Permit,
)
}
}
}

View file

@ -1,168 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.rawCompact
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.InsightsUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
@Stable
internal class InsightsConverter(
private val appCurrency: Provider<AppCurrency>,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
private val onIntervalChanged: (PriceChangeInterval) -> Unit,
) : Converter<TokenMarketInfo.Insights, InsightsUM> {
override fun convert(value: TokenMarketInfo.Insights): InsightsUM {
return with(value) {
InsightsUM(
h24Info = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.day,
holdersChange = holdersChange?.day,
liquidityChange = liquidityChange?.day,
buyPressureChange = buyPressureChange?.day,
),
weekInfo = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.week,
holdersChange = holdersChange?.week,
liquidityChange = liquidityChange?.week,
buyPressureChange = buyPressureChange?.week,
),
monthInfo = createInfoPointList(
experiencedBuyerChange = experiencedBuyerChange?.month,
holdersChange = holdersChange?.month,
liquidityChange = liquidityChange?.month,
buyPressureChange = buyPressureChange?.month,
),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_insights),
body = resourceReference(
R.string.markets_insights_info_description_message,
wrappedList(value.sourceNetworks.joinToString { it.name }),
),
),
)
},
onIntervalChanged = onIntervalChanged,
)
}
}
private fun createInfoPointList(
experiencedBuyerChange: BigDecimal?,
holdersChange: BigDecimal?,
liquidityChange: BigDecimal?,
buyPressureChange: BigDecimal?,
): ImmutableList<InfoPointUM> {
return listOfNotNull(
experiencedBuyerChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = experiencedBuyerChange.convertChange(),
change = experiencedBuyerChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_experienced_buyers_full),
body = resourceReference(R.string.markets_token_details_experienced_buyers_description),
),
)
},
)
},
buyPressureChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = buyPressureChange.convertChange(isFiatValue = true),
change = buyPressureChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_buy_pressure_full),
body = resourceReference(R.string.markets_token_details_buy_pressure_description),
),
)
},
)
},
holdersChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = holdersChange.convertChange(),
change = holdersChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_holders_full),
body = resourceReference(R.string.markets_token_details_holders_description),
),
)
},
)
},
liquidityChange?.let {
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = liquidityChange.convertChange(),
change = liquidityChange.changeType(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_liquidity_full),
body = resourceReference(R.string.markets_token_details_liquidity_description),
),
)
},
)
},
).toImmutableList()
}
private fun BigDecimal.changeType(): InfoPointUM.ChangeType? {
return when {
this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP
this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN
else -> null
}
}
private fun BigDecimal.convertChange(isFiatValue: Boolean = false): String {
val value = if (isFiatValue) {
this.abs().format {
val currency = appCurrency()
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).compact()
}
} else {
this.abs().format {
rawCompact()
}
}
return when {
this > BigDecimal.ZERO -> StringsSigns.PLUS + value
this < BigDecimal.ZERO -> StringsSigns.MINUS + value
this == BigDecimal.ZERO -> value
else -> StringsSigns.DASH_SIGN
}
}
}

View file

@ -1,49 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.details.impl.ui.state.LinksUM.Link
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
@Stable
internal class LinksConverter(
private val onLinkClick: (LinksUM.Link) -> Unit,
) : Converter<TokenMarketInfo.Links, LinksUM> {
override fun convert(value: TokenMarketInfo.Links): LinksUM {
return LinksUM(
officialLinks = value.officialLinks?.map { it.convert() }.orEmpty().toImmutableList(),
social = value.social?.map { it.convert() }.orEmpty().toImmutableList(),
repository = value.repository?.map { it.convert() }.orEmpty().toImmutableList(),
blockchainSite = value.blockchainSite?.map { it.convert() }.orEmpty().toImmutableList(),
onLinkClick = onLinkClick,
)
}
private fun TokenMarketInfo.Link.convert(): LinksUM.Link {
return LinksUM.Link(
title = title,
iconRes = getIconById(id),
url = link,
)
}
private fun getIconById(id: String?): Int {
return when (id) {
"linkedin" -> R.drawable.ic_linkedin_24
"discord" -> R.drawable.ic_discord_24
"youtube" -> R.drawable.ic_youtube_24
"telegram" -> R.drawable.ic_telegram_24
"github" -> R.drawable.ic_github_24
"twitter" -> R.drawable.ic_twitter_24
"facebook" -> R.drawable.ic_facebook_24
"reddit" -> R.drawable.ic_reddit_24
"instagram" -> R.drawable.ic_instagram_24
"whitepaper" -> R.drawable.ic_doc_24
else -> R.drawable.ic_arrow_top_right_24
}
}
}

View file

@ -1,152 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.MetricsUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Stable
internal class MetricsConverter(
private val appCurrency: Provider<AppCurrency>,
private val tokenSymbol: String,
private val onInfoClick: (InfoBottomSheetContent) -> Unit,
) : Converter<TokenMarketInfo.Metrics, MetricsUM> {
@Suppress("LongMethod")
override fun convert(value: TokenMarketInfo.Metrics): MetricsUM {
return with(value) {
MetricsUM(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = marketCap.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_market_capitalization_full,
),
body = resourceReference(
R.string.markets_token_details_market_capitalization_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_rating),
value = marketRating?.toString() ?: StringsSigns.DASH_SIGN,
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_market_rating_full),
body = resourceReference(R.string.markets_token_details_market_rating_description),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = volume24h.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_trading_volume_full),
body = resourceReference(
R.string.markets_token_details_trading_volume_24h_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = fullyDilutedValuation.formatAmount(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(
R.string.markets_token_details_fully_diluted_valuation_full,
),
body = resourceReference(
R.string.markets_token_details_fully_diluted_valuation_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = circulatingSupply.formatAmount(crypto = true),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_circulating_supply_full),
body = resourceReference(
R.string.markets_token_details_circulating_supply_description,
),
),
)
},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_max_supply),
value = maxSupply.formatMaxSupply(),
onInfoClick = {
onInfoClick(
InfoBottomSheetContent(
title = resourceReference(R.string.markets_token_details_max_supply_full),
body = resourceReference(R.string.markets_token_details_total_supply_description),
),
)
},
),
),
)
}
}
private fun BigDecimal?.formatMaxSupply(): String {
when (this) {
null -> return StringsSigns.DASH_SIGN
BigDecimal.ZERO -> return StringsSigns.INFINITY_SIGN
}
return this.formatAmount(crypto = true)
}
private fun BigDecimal?.formatAmount(crypto: Boolean = false): String {
if (this == null) return StringsSigns.DASH_SIGN
return if (crypto) {
format {
crypto(
symbol = tokenSymbol,
decimals = 2,
).compact()
}
} else {
val currency = appCurrency()
format {
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).compact()
}
}
}
}

View file

@ -1,71 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
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.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import java.math.BigDecimal
import java.math.RoundingMode
@Stable
internal class PricePerformanceConverter(
private val appCurrency: Provider<AppCurrency>,
private val onIntervalChanged: (PriceChangeInterval) -> Unit,
) {
fun convert(value: TokenMarketInfo.PricePerformance, currentPrice: BigDecimal): PricePerformanceUM {
return PricePerformanceUM(
h24 = value.day.convert(currentPrice),
month = value.month.convert(currentPrice),
all = value.allTime.convert(currentPrice),
onIntervalChanged = onIntervalChanged,
)
}
private fun TokenMarketInfo.Range?.convert(currentPrice: BigDecimal): PricePerformanceUM.Value {
if (this == null || this.low == null || this.high == null) {
return PricePerformanceUM.Value(
low = StringsSigns.DASH_SIGN,
high = StringsSigns.DASH_SIGN,
indicatorFraction = 0f,
)
}
return PricePerformanceUM.Value(
low = low.convert(),
high = high.convert(),
indicatorFraction = calculateFraction(currentPrice),
)
}
private fun BigDecimal?.convert(): String {
val currency = appCurrency()
return format {
fiat(
fiatCurrencyCode = currency.code,
fiatCurrencySymbol = currency.symbol,
).price()
}
}
private fun TokenMarketInfo.Range.calculateFraction(currentPrice: BigDecimal): Float {
val lowValue = low
val highValue = high
return when {
lowValue == null || highValue == null || highValue == BigDecimal.ZERO || currentPrice < lowValue -> 0f
currentPrice > highValue || lowValue == highValue -> 1f
else -> {
(currentPrice - lowValue).divide(highValue - lowValue, RoundingMode.HALF_UP)
.setScale(2, RoundingMode.HALF_UP)
.toFloat().coerceAtMost(1f)
}
}
}
}

View file

@ -1,56 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.model.formatter.MarketsDateTimeFormatters
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM
import com.tangem.features.markets.impl.R
import com.tangem.utils.converter.Converter
@Stable
internal class SecurityScoreConverter(
private val onSecurityScoreInfoClick: (SecurityScoreBottomSheetContent) -> Unit,
private val onSecurityScoreProviderLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit,
) : Converter<TokenMarketInfo.SecurityData, SecurityScoreUM> {
override fun convert(value: TokenMarketInfo.SecurityData): SecurityScoreUM {
val ratingsCount = value.securityScoreProviderData.size
return SecurityScoreUM(
score = value.totalSecurityScore,
description = pluralReference(
id = R.plurals.markets_token_details_based_on_ratings,
count = ratingsCount,
formatArgs = wrappedList(ratingsCount),
),
onInfoClick = {
onSecurityScoreInfoClick(
SecurityScoreBottomSheetContent(
title = resourceReference(R.string.markets_token_details_security_score),
description = resourceReference(R.string.markets_token_details_security_score_description),
providers = value.securityScoreProviderData.map { provider ->
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = provider.providerName,
lastAuditDate = provider.lastAuditDate?.let { date ->
MarketsDateTimeFormatters.formatAsDate(date.millis)
},
score = provider.securityScore,
urlData = provider.urlData?.let { urlData ->
SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = urlData.fullUrl,
rootHost = urlData.rootHost,
)
},
iconUrl = provider.iconUrl,
)
},
onProviderLinkClick = onSecurityScoreProviderLinkClick,
),
)
},
)
}
}

View file

@ -1,81 +0,0 @@
package com.tangem.features.markets.details.impl.model.converters
import androidx.compose.runtime.Stable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.details.impl.ui.state.ListedOnUM
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
@Stable
@Suppress("LongParameterList")
internal class TokenMarketInfoConverter(
private val appCurrency: Provider<AppCurrency>,
private val needApplyFCARestrictions: Provider<Boolean>,
private val onInfoClick: (TangemBottomSheetConfigContent) -> Unit,
private val onListedOnClick: (Int) -> Unit,
onSecurityScoreInfoClick: (SecurityScoreBottomSheetContent) -> Unit,
onLinkClick: (LinksUM.Link) -> Unit,
onSecurityScoreProviderLinkClick: (SecurityScoreBottomSheetContent.SecurityScoreProviderUM) -> Unit,
onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
onInsightsIntervalChanged: (PriceChangeInterval) -> Unit,
) : Converter<TokenMarketInfo, MarketsTokenDetailsUM.InformationBlocks> {
private val insightsConverter = InsightsConverter(
appCurrency = appCurrency,
onInfoClick = onInfoClick,
onIntervalChanged = onInsightsIntervalChanged,
)
private val securityScoreConverter = SecurityScoreConverter(
onSecurityScoreInfoClick = onSecurityScoreInfoClick,
onSecurityScoreProviderLinkClick = onSecurityScoreProviderLinkClick,
)
private val pricePerformanceConverter = PricePerformanceConverter(
appCurrency = appCurrency,
onIntervalChanged = onPricePerformanceIntervalChanged,
)
private val linksConverter = LinksConverter(onLinkClick = onLinkClick)
override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks {
val metricsConverter = MetricsConverter(
tokenSymbol = value.symbol,
appCurrency = appCurrency,
onInfoClick = onInfoClick,
)
val exchangesAmount = value.exchangesAmount
val insights = if (needApplyFCARestrictions()) {
null
} else {
value.insights?.let { insightsConverter.convert(it) }
}
val securityScore = if (needApplyFCARestrictions()) {
null
} else {
value.securityData?.let { securityScoreConverter.convert(it) }
}
return MarketsTokenDetailsUM.InformationBlocks(
insights = insights,
securityScore = securityScore,
metrics = value.metrics?.let { metricsConverter.convert(it) },
pricePerformance = value.pricePerformance?.let { performance ->
pricePerformanceConverter.convert(
value = performance,
currentPrice = value.quotes.currentPrice,
)
},
listedOn = if (exchangesAmount != null && exchangesAmount > 0) {
ListedOnUM.Content(onClick = { onListedOnClick(exchangesAmount) }, amount = exchangesAmount)
} else {
ListedOnUM.Empty
},
links = value.links?.let { linksConverter.convert(it) },
)
}
}

View file

@ -1,76 +0,0 @@
package com.tangem.features.markets.details.impl.model.formatter
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.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenQuotes
import java.math.BigDecimal
import java.math.RoundingMode
internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInterval): String {
val percent = when (interval) {
PriceChangeInterval.H24 -> h24ChangePercent
PriceChangeInterval.WEEK -> weekChangePercent
PriceChangeInterval.MONTH -> monthChangePercent
PriceChangeInterval.MONTH3 -> m3ChangePercent
PriceChangeInterval.MONTH6 -> m6ChangePercent
PriceChangeInterval.YEAR -> yearChangePercent
PriceChangeInterval.ALL_TIME -> allTimeChangePercent
}
return percent?.format { percent() }.orEmpty()
}
internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): BigDecimal? {
return when (interval) {
PriceChangeInterval.H24 -> h24ChangePercent
PriceChangeInterval.WEEK -> weekChangePercent
PriceChangeInterval.MONTH -> monthChangePercent
PriceChangeInterval.MONTH3 -> m3ChangePercent
PriceChangeInterval.MONTH6 -> m6ChangePercent
PriceChangeInterval.YEAR -> yearChangePercent
PriceChangeInterval.ALL_TIME -> allTimeChangePercent
}
}
@Suppress("MagicNumber")
internal fun BigDecimal?.percentChangeType(): PriceChangeType {
val scaled = this?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
@Suppress("MagicNumber")
internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: BigDecimal): BigDecimal {
return if (previousPrice == BigDecimal.ZERO) {
BigDecimal.ZERO
} else {
currentPrice.subtract(previousPrice).divide(previousPrice, 4, RoundingMode.HALF_UP)
}
}
internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType {
val current = getFiatPriceAmountWithScale(value = currentPrice).first
val updated = getFiatPriceAmountWithScale(value = updatedPrice).first
return when {
updated > current -> PriceChangeType.UP
updated < current -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
internal fun PriceChangeType.toChartType(): MarketChartLook.Type {
return when (this) {
PriceChangeType.UP -> MarketChartLook.Type.Growing
PriceChangeType.DOWN -> MarketChartLook.Type.Falling
PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral
}
}

View file

@ -1,140 +0,0 @@
package com.tangem.features.markets.details.impl.model.formatter
import com.tangem.core.ui.extensions.TextReference
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.utils.DateTimeFormatters
import com.tangem.core.ui.utils.formatAsDateTime
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.impl.R
import com.tangem.utils.H24_MILLIS
import com.tangem.utils.WEEK_MILLIS
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import java.math.BigDecimal
internal object MarketsDateTimeFormatters {
private val dateTimeMMMFormatter by lazy {
DateTimeFormatters.getBestFormatterBySkeleton("dd MMM Hm")
}
private val dateFormatter = DateTimeFormatters.dateDDMMYYYY
fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String {
return when (interval) {
PriceChangeInterval.H24 -> { value: BigDecimal ->
value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter)
}
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
PriceChangeInterval.MONTH6,
-> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd)
}
PriceChangeInterval.YEAR -> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd)
}
PriceChangeInterval.ALL_TIME -> { value ->
value.toLong().formatAsDateTime(DateTimeFormatters.dateYYYY)
}
}
}
fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference {
return when (interval) {
PriceChangeInterval.H24 -> resourceReference(R.string.common_today)
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
startTimestamp.formatAsDateTime(dateTimeMMMFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
startTimestamp.formatAsDateTime(dateFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.ALL_TIME -> resourceReference(R.string.common_all)
}
}
fun formatDateByIntervalWithMarker(interval: PriceChangeInterval, markerTimestamp: BigDecimal): TextReference {
return when (interval) {
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
markerTimestamp.toLong().formatAsDateTime(dateTimeMMMFormatter),
),
resourceReference(R.string.common_now),
),
)
}
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
PriceChangeInterval.ALL_TIME,
-> {
resourceReference(
R.string.common_range_with_space,
wrappedList(
stringReference(
markerTimestamp.toLong().formatAsDateTime(dateFormatter),
),
resourceReference(R.string.common_now),
),
)
}
}
}
@Suppress("MagicNumber")
fun getStartTimestampByInterval(interval: PriceChangeInterval, currentTimestamp: Long): Long {
return when (interval) {
PriceChangeInterval.H24 -> currentTimestamp - H24_MILLIS
PriceChangeInterval.WEEK -> currentTimestamp - WEEK_MILLIS
PriceChangeInterval.MONTH -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(1).millis
PriceChangeInterval.MONTH3 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(3).millis
PriceChangeInterval.MONTH6 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(6).millis
PriceChangeInterval.YEAR -> DateTime(currentTimestamp, DateTimeZone.UTC).minusYears(1).millis
PriceChangeInterval.ALL_TIME -> 0
}
}
fun getDefaultDateTimeString(interval: PriceChangeInterval, currentTimestamp: Long): TextReference {
return formatDateByInterval(
interval = interval,
startTimestamp = getStartTimestampByInterval(
interval = interval,
currentTimestamp = currentTimestamp,
),
)
}
fun formatAsDate(timestamp: Long): String {
return timestamp.formatAsDateTime(dateFormatter)
}
}

View file

@ -1,96 +0,0 @@
package com.tangem.features.markets.details.impl.model.state
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
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.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenQuotes
import com.tangem.features.markets.details.impl.model.converters.PricePerformanceConverter
import com.tangem.features.markets.details.impl.model.formatter.*
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.utils.Provider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import org.joda.time.DateTime
import java.math.BigDecimal
internal class QuotesStateUpdater(
private val currentAppCurrency: Provider<AppCurrency>,
private val state: MutableStateFlow<MarketsTokenDetailsUM>,
private val currentQuotes: MutableStateFlow<TokenQuotes>,
private val lastUpdatedTimestamp: MutableStateFlow<Long>,
private val currentTokenInfo: MutableStateFlow<TokenMarketInfo?>,
private val onPricePerformanceIntervalChanged: (PriceChangeInterval) -> Unit,
) {
private val pricePerformanceConverter = PricePerformanceConverter(
currentAppCurrency,
onIntervalChanged = onPricePerformanceIntervalChanged,
)
suspend fun updateQuotes(newQuotes: TokenQuotes) {
val triggerPriceChangeType = getFormattedPriceChange(
currentPrice = currentQuotes.value.currentPrice,
updatedPrice = newQuotes.currentPrice,
)
val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) {
triggeredEvent(
data = triggerPriceChangeType,
onConsume = {
state.update { it.copy(triggerPriceChange = consumedEvent()) }
},
)
} else {
consumedEvent()
}
val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval)
val priceChangeType = percent.percentChangeType()
// wait until marker is removed
state.first { it.isMarkerSet.not() }
currentQuotes.value = newQuotes
lastUpdatedTimestamp.value = DateTime.now().millis
state.update { stateToUpdate ->
stateToUpdate.copy(
priceText = newQuotes.currentPrice.format {
fiat(
fiatCurrencySymbol = currentAppCurrency().symbol,
fiatCurrencyCode = currentAppCurrency().code,
).price()
},
priceChangePercentText = newQuotes.getFormattedPercentByInterval(
interval = stateToUpdate.selectedInterval,
),
priceChangeType = priceChangeType,
triggerPriceChange = trigger,
dateTimeText = MarketsDateTimeFormatters.getDefaultDateTimeString(
stateToUpdate.selectedInterval,
currentTimestamp = lastUpdatedTimestamp.value,
),
body = stateToUpdate.body.updatePricePerformance(newQuotes.currentPrice),
)
}
}
private fun MarketsTokenDetailsUM.Body.updatePricePerformance(price: BigDecimal): MarketsTokenDetailsUM.Body {
val currentPricePerformance = currentTokenInfo.value?.pricePerformance ?: return this
return if (this is MarketsTokenDetailsUM.Body.Content) {
copy(
infoBlocks = infoBlocks.copy(
pricePerformance = pricePerformanceConverter.convert(currentPricePerformance, price),
),
)
} else {
this
}
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.features.markets.details.impl.model.state
import com.tangem.domain.markets.TokenMarketInfo
internal sealed class TokenNetworksState {
data object Loading : TokenNetworksState()
data object NoNetworksAvailable : TokenNetworksState()
data class NetworksAvailable(val networks: List<TokenMarketInfo.Network>) : TokenNetworksState()
}

View file

@ -1,351 +0,0 @@
package com.tangem.features.markets.details.impl.ui
import android.content.res.Configuration
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
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.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.components.*
import com.tangem.features.markets.details.impl.ui.preview.MarketsTokenDetailsPreview
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongParameterList")
@Composable
internal fun MarketsTokenDetailsContent(
state: MarketsTokenDetailsUM,
backgroundColor: Color,
addTopBarStatusBarPadding: Boolean,
onBackClick: () -> Unit,
onHeaderSizeChange: (Dp) -> Unit,
backButtonEnabled: Boolean,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
Content(
modifier = modifier,
backgroundColor = backgroundColor,
state = state,
onBackClick = onBackClick,
onHeaderSizeChange = onHeaderSizeChange,
backButtonEnabled = backButtonEnabled,
portfolioBlock = portfolioBlock,
isAccountEnabled = isAccountEnabled,
addTopBarStatusBarInsets = addTopBarStatusBarPadding,
)
when (state.bottomSheetConfig.content) {
is InfoBottomSheetContent -> InfoBottomSheet(config = state.bottomSheetConfig)
is SecurityScoreBottomSheetContent -> SecurityScoreBottomSheet(config = state.bottomSheetConfig)
is ExchangesBottomSheetContent -> ExchangesBottomSheet(config = state.bottomSheetConfig)
}
}
@Suppress("LongParameterList")
@Composable
private fun Content(
state: MarketsTokenDetailsUM,
backgroundColor: Color,
addTopBarStatusBarInsets: Boolean,
onBackClick: () -> Unit,
onHeaderSizeChange: (Dp) -> Unit,
backButtonEnabled: Boolean,
isAccountEnabled: Boolean,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
val lazyListState = rememberLazyListState()
Column(
modifier = modifier
.drawBehind { drawRect(backgroundColor) }
.let { if (addTopBarStatusBarInsets) it.statusBarsPadding() else it }
.fillMaxSize(),
) {
TopBar(
modifier = Modifier.onGloballyPositioned { coordinates ->
if (coordinates.size.height > 0) {
with(density) {
onHeaderSizeChange(coordinates.size.height.toDp())
}
}
},
lazyListState = lazyListState,
tokenName = state.tokenName,
tokenPrice = state.priceText,
isBackButtonEnabled = backButtonEnabled,
onBackClick = onBackClick,
)
SpacerH4()
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight),
) {
item("header") {
Header(
state = state,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
item { SpacerH16() }
item("intervalSelector") {
IntervalSelector(
trendInterval = state.selectedInterval,
onIntervalClick = state.onSelectedIntervalChange,
isEnabled = state.body !is MarketsTokenDetailsUM.Body.Nothing,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
item { SpacerH32() }
item("chart") {
MarketTokenDetailsChart(
modifier = Modifier.fillMaxWidth(),
backgroundColor = backgroundColor,
state = state.chartState,
)
}
item { SpacerH16() }
tokenMarketDetailsBody(
state = state.body,
isAccountEnabled = isAccountEnabled,
portfolioBlock = portfolioBlock,
)
}
}
}
@Composable
private fun TopBar(
lazyListState: LazyListState,
tokenName: String,
tokenPrice: String,
isBackButtonEnabled: Boolean,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shouldShowPriceSubtitle by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex > 1
}
}
TangemTopAppBar(
modifier = modifier,
title = tokenName,
subtitle = if (shouldShowPriceSubtitle) tokenPrice else null,
startButton = TopAppBarButtonUM.Back(
onBackClicked = onBackClick,
enabled = isBackButtonEnabled,
),
)
}
@Composable
private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
TokenPriceText(
price = state.priceText,
triggerPriceChange = state.triggerPriceChange,
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
Text(
text = state.dateTimeText.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
if (state.priceChangePercentText != null) {
PriceChangeInPercent(
valueInPercent = state.priceChangePercentText,
type = state.priceChangeType,
textStyle = TangemTheme.typography.caption2,
)
}
}
}
SpacerW4()
CoinIcon(
modifier = Modifier.requiredSize(TangemTheme.dimens.size48),
url = state.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
}
}
@Composable
private fun TokenPriceText(
price: String,
triggerPriceChange: StateEvent<PriceChangeType>,
modifier: Modifier = Modifier,
) {
val growColor = TangemTheme.colors.text.accent
val fallColor = TangemTheme.colors.text.warning
val generalColor = TangemTheme.colors.text.primary1
val color = remember(generalColor) { Animatable(generalColor) }
EventEffect(triggerPriceChange) { changeType ->
val nextColor = when (changeType) {
PriceChangeType.UP,
-> growColor
PriceChangeType.DOWN -> fallColor
PriceChangeType.NEUTRAL -> return@EventEffect
}
color.animateTo(nextColor, snap())
color.animateTo(generalColor, tween(durationMillis = 500))
}
Text(
text = price,
modifier = modifier,
color = color.value,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.head.fontSize),
maxLines = 1,
style = TangemTheme.typography.head,
)
}
@Composable
private fun IntervalSelector(
trendInterval: PriceChangeInterval,
isEnabled: Boolean,
onIntervalClick: (PriceChangeInterval) -> Unit,
modifier: Modifier = Modifier,
) {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
PriceChangeInterval.MONTH3,
PriceChangeInterval.MONTH6,
PriceChangeInterval.YEAR,
PriceChangeInterval.ALL_TIME,
),
color = TangemTheme.colors.button.secondary,
initialSelectedItem = trendInterval,
onClick = onIntervalClick,
isEnabled = isEnabled,
modifier = modifier,
) {
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = if (isEnabled) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.disabled
},
)
}
}
}
@Composable
fun PriceChangeInterval.getText(): TextReference {
return when (this) {
PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title)
PriceChangeInterval.WEEK -> resourceReference(R.string.markets_selector_interval_7d_title)
PriceChangeInterval.MONTH -> resourceReference(R.string.markets_selector_interval_1m_title)
PriceChangeInterval.MONTH3 -> resourceReference(R.string.markets_selector_interval_3m_title)
PriceChangeInterval.MONTH6 -> resourceReference(R.string.markets_selector_interval_6m_title)
PriceChangeInterval.YEAR -> resourceReference(R.string.markets_selector_interval_1y_title)
PriceChangeInterval.ALL_TIME -> resourceReference(R.string.markets_selector_interval_all_title)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun MarketsTokenDetailsContent_Preview(
@PreviewParameter(MarketsTokenDetailsContentPreviewProvider::class) params: MarketsTokenDetailsUM,
) {
TangemThemePreview {
MarketsTokenDetailsContent(
state = params,
onHeaderSizeChange = {},
onBackClick = {},
backgroundColor = TangemTheme.colors.background.tertiary,
portfolioBlock = {},
backButtonEnabled = true,
isAccountEnabled = true,
addTopBarStatusBarPadding = false,
)
}
}
private class MarketsTokenDetailsContentPreviewProvider : PreviewParameterProvider<MarketsTokenDetailsUM> {
override val values: Sequence<MarketsTokenDetailsUM>
get() = sequenceOf(
MarketsTokenDetailsPreview.loadingState,
MarketsTokenDetailsPreview.contentState,
)
}
// endregion

View file

@ -1,191 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.details.impl.ui.state.ExchangesBottomSheetContent
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.toImmutableList
/**
* Exchanges bottom sheet
*
* @param config bottom sheet config
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun ExchangesBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<ExchangesBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { Title(textResId = it.titleResId, onBackClick = config.onDismissRequest) },
content = { content ->
Box {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomBarHeight),
) {
item(key = "subtitle") {
Subtitle(
subtitleRes = content.subtitleResId,
volumeReference = content.volumeReference,
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing8,
),
)
}
items(
items = content.exchangeItems,
key = TokenItemState::id,
itemContent = { TokenItem(state = it, isBalanceHidden = false) },
)
}
if (content is ExchangesBottomSheetContent.Error) {
Error(
content = content,
modifier = Modifier.align(Alignment.Center),
)
}
}
},
)
}
@Composable
private fun Title(@StringRes textResId: Int, onBackClick: () -> Unit) {
TangemTopAppBar(
title = stringResourceSafe(id = textResId),
startButton = TopAppBarButtonUM.Back(onBackClicked = onBackClick),
)
}
@Composable
private fun Subtitle(@StringRes subtitleRes: Int, volumeReference: TextReference, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
SubtitleText(textReference = resourceReference(id = subtitleRes))
SubtitleText(textReference = volumeReference)
}
}
@Composable
private fun SubtitleText(textReference: TextReference) {
Text(
text = textReference.resolveReference(),
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Composable
private fun Error(content: ExchangesBottomSheetContent.Error, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(id = content.message),
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
style = TangemTheme.typography.caption1,
)
SpacerH12()
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(id = R.string.alert_button_try_again),
onClick = content.onRetryClick,
),
)
}
}
@Preview
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ExchangesBottomSheet(
@PreviewParameter(ExchangesBottomSheetContentProvider::class) content: ExchangesBottomSheetContent,
) {
TangemThemePreview {
ExchangesBottomSheet(
config = TangemBottomSheetConfig(
onDismissRequest = {},
content = content,
isShown = true,
),
)
}
}
private class ExchangesBottomSheetContentProvider : CollectionPreviewParameterProvider<ExchangesBottomSheetContent>(
listOf(
ExchangesBottomSheetContent.Loading(exchangesCount = 13),
ExchangesBottomSheetContent.Error(onRetryClick = {}),
ExchangesBottomSheetContent.Content(
exchangeItems = List(size = 13) { index ->
TokenItemState.Content(
id = index.toString(),
iconState = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.ic_facebook_24,
isGrayscale = false,
shouldShowCustomBadge = false,
),
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "OKX")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "$67.52M"),
subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference(value = "CEX")),
subtitle2State = TokenItemState.Subtitle2State.LabelContent(
auditLabelUM = AuditLabelUM(
text = stringReference("Caution"),
type = AuditLabelUM.Type.Warning,
),
),
onItemClick = {},
onItemLongClick = {},
)
}
.toImmutableList(),
),
),
)

View file

@ -1,75 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent
import com.tangem.features.markets.impl.R
import dev.jeziellago.compose.markdowntext.MarkdownText
@Composable
internal fun InfoBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<InfoBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { TangemBottomSheetTitle(title = it.title) },
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
MarkdownText(
markdown = content.body.resolveReference(),
disableLinkMovementMethod = true,
linkifyMask = 0,
syntaxHighlightColor = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2.copy(
color = TangemTheme.colors.text.secondary,
),
)
if (content.generatedAINotificationUM != null) {
AdditionalInfoNotification(
onClick = content.generatedAINotificationUM.onClick,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
}
SpacerH(bottomBarHeight)
}
},
)
}
@Composable
private fun AdditionalInfoNotification(onClick: () -> Unit, modifier: Modifier = Modifier) {
Notification(
config = NotificationConfig(
subtitle = TextReference.Res(id = R.string.information_generated_with_ai),
iconResId = R.drawable.ic_magic_28,
onClick = onClick,
shouldShowArrowIcon = false,
),
modifier = modifier,
subtitleColor = TangemTheme.colors.text.primary1,
containerColor = TangemTheme.colors.button.disabled,
iconTint = TangemTheme.colors.icon.accent,
)
}

View file

@ -1,186 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
@Composable
internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
if (infoPointUM.onInfoClick != null) {
TooltipText(
text = infoPointUM.title,
onInfoClick = infoPointUM.onInfoClick,
textStyle = TangemTheme.typography.caption2,
)
} else {
Text(
text = infoPointUM.title.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row {
Text(
text = infoPointUM.value,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
if (infoPointUM.change != null) {
SpacerW4()
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size8)
.align(Alignment.CenterVertically),
imageVector = ImageVector.vectorResource(
id = when (infoPointUM.change) {
InfoPointUM.ChangeType.UP -> R.drawable.ic_arrow_up_8
InfoPointUM.ChangeType.DOWN -> R.drawable.ic_arrow_down_8
},
),
tint = when (infoPointUM.change) {
InfoPointUM.ChangeType.UP -> TangemTheme.colors.icon.accent
InfoPointUM.ChangeType.DOWN -> TangemTheme.colors.icon.warning
},
contentDescription = null,
)
}
}
}
}
@Composable
internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolean = false) {
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8),
horizontalAlignment = Alignment.Start,
) {
if (withTooltip) {
Box(
modifier = Modifier
.requiredHeight(TangemTheme.dimens.size16)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.caption2,
textSizeHeight = false,
)
}
} else {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.caption2,
textSizeHeight = true,
)
}
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
style = TangemTheme.typography.body1,
textSizeHeight = true,
)
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
Column(
modifier = Modifier
.width(150.dp)
.background(TangemTheme.colors.background.tertiary),
) {
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000,000",
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000,000",
onInfoClick = { },
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000",
change = InfoPointUM.ChangeType.UP,
onInfoClick = { },
),
)
InfoPoint(
infoPointUM = InfoPointUM(
title = stringReference("Market Cap"),
value = "$1,000,000",
change = InfoPointUM.ChangeType.DOWN,
onInfoClick = { },
),
)
}
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewShimmer() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
Column(
modifier = Modifier
.width(150.dp)
.background(TangemTheme.colors.background.tertiary),
) {
InfoPointShimmer(modifier = Modifier.fillMaxWidth())
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
}
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -1,204 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.block.information.GridItems
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.InsightsUM
import com.tangem.features.markets.details.impl.ui.getText
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) {
var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) }
InformationBlock(
modifier = modifier,
title = {
TooltipText(
text = resourceReference(R.string.markets_token_details_insights),
textStyle = TangemTheme.typography.subtitle2,
onInfoClick = state.onInfoClick,
)
},
action = {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.WEEK,
PriceChangeInterval.MONTH,
),
initialSelectedItem = PriceChangeInterval.H24,
onClick = { interval ->
currentInterval = interval
state.onIntervalChanged(interval)
},
modifier = Modifier.width(IntrinsicSize.Min),
) { interval ->
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
horizontal = 14.dp,
vertical = 4.dp,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = interval.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
},
content = {
val infoPoints = when (currentInterval) {
PriceChangeInterval.H24 -> state.h24Info
PriceChangeInterval.WEEK -> state.weekInfo
PriceChangeInterval.MONTH -> state.monthInfo
else -> state.h24Info
}
GridItems(
items = infoPoints,
itemContent = { infoPoint ->
InfoPoint(
modifier = Modifier.align(Alignment.CenterStart),
infoPointUM = infoPoint,
)
},
)
},
)
}
@Composable
internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) {
val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() }
val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() }
val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4
InformationBlock(
modifier = modifier,
title = {
RectangleShimmer(
modifier = Modifier
.height(headerHeight)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
},
content = {
GridItems(
items = List(size = 4) { it }.toImmutableList(),
horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
itemContent = {
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
)
},
)
},
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
InsightsBlock(
state = InsightsUM(
h24Info = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000 000 000",
),
),
weekInfo = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000 000",
),
),
monthInfo = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_experienced_buyers),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_buy_pressure),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_holders),
value = "1 000",
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_liquidity),
value = "1 000",
),
),
onInfoClick = {},
onIntervalChanged = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = { ContentPreview() },
shimmerContent = { InsightsBlockPlaceholder() },
)
}
}

View file

@ -1,219 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.ChipShimmer
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.chip.Chip
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
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.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.LinksUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
contentHorizontalPadding = 0.dp,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_links),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
content = {
Column {
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_official_links),
links = state.officialLinks,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_social),
links = state.social,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_repository),
links = state.repository,
onLinkClick = state.onLinkClick,
)
SubBlock(
title = stringResourceSafe(id = R.string.markets_token_details_blockchain_site),
links = state.blockchainSite,
onLinkClick = state.onLinkClick,
lastBlock = true,
)
}
},
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SubBlock(
links: ImmutableList<LinksUM.Link>,
onLinkClick: (LinksUM.Link) -> Unit,
modifier: Modifier = Modifier,
lastBlock: Boolean = false,
title: String = "Official links",
) {
if (links.isEmpty()) return
DividerContainer(
modifier = modifier,
showDivider = !lastBlock,
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Text(
text = title,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
links.fastForEach { link ->
Chip(
text = stringReference(link.title),
iconResId = link.iconRes,
onClick = { onLinkClick(link) },
)
}
}
}
}
}
@Composable
fun LinksBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
contentHorizontalPadding = 0.dp,
title = {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.subtitle2,
)
},
content = {
Column {
SubBlockPlaceholder()
SubBlockPlaceholder()
SubBlockPlaceholder(lastBlock = true)
}
},
)
}
@Composable
private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolean = false) {
DividerContainer(
modifier = modifier,
showDivider = !lastBlock,
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
TextShimmer(
modifier = Modifier.width(78.dp),
style = TangemTheme.typography.caption2,
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
repeat(times = 3) {
ChipShimmer(
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
LinksBlock(
state = LinksUM(
officialLinks = persistentListOf(
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Website",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
social = persistentListOf(
LinksUM.Link(
title = "Twitter",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
LinksUM.Link(
title = "Facebook",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
repository = persistentListOf(
LinksUM.Link(
title = "Github",
iconRes = R.drawable.ic_plus_24,
url = "https://tangem.com",
),
),
blockchainSite = persistentListOf(),
onLinkClick = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaceholderPreview() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = { LinksBlockPlaceholder() },
actualContent = { ContentPreview() },
)
}
}

View file

@ -1,138 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.R
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.details.impl.ui.state.ListedOnUM
import kotlinx.coroutines.delay
/**
* "Listed on" block
*
* @param state block state
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun ListedOnBlock(state: ListedOnUM, modifier: Modifier = Modifier) {
Box(modifier = modifier) {
InformationBlock(
title = {
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = Modifier
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
},
) {
Description(
state = state,
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
)
}
if (state is ListedOnUM.Content) {
Icon(
painter = painterResource(id = R.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = TangemTheme.dimens.spacing12),
)
}
}
}
@Composable
internal fun ListedOnBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
title = {
TextShimmer(
style = TangemTheme.typography.subtitle2,
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
)
},
modifier = modifier,
) {
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.fillMaxWidth(fraction = 0.3f)
.padding(bottom = TangemTheme.dimens.spacing12),
)
}
}
@Composable
private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) {
Text(
text = state.description.resolveReference(),
modifier = modifier,
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
)
}
@Preview(widthDp = 328, heightDp = 68)
@Preview(name = "Dark Theme", widthDp = 328, heightDp = 68, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ListedOnBlock(@PreviewParameter(ListenOnUMProvider::class) state: ListedOnUM?) {
TangemThemePreview {
if (state == null) {
ListedOnBlockPlaceholder()
} else {
ListedOnBlock(state = state)
}
}
}
@Preview
@Composable
private fun Preview_ListedOnBlock_StateChanging() {
var state by remember { mutableStateOf<ListedOnUM?>(value = null) }
Preview_ListedOnBlock(state = state)
LaunchedEffect(key1 = null) {
delay(timeMillis = 3000)
state = ListedOnUM.Empty
}
}
private class ListenOnUMProvider : CollectionPreviewParameterProvider<ListedOnUM?>(
collection = listOf(
ListedOnUM.Empty,
ListedOnUM.Content(onClick = {}, amount = 5),
null,
),
)

View file

@ -1,84 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import com.tangem.common.ui.charts.MarketChart
import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.rememberMarketChartState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.core.ui.components.UnableToLoadData
@Composable
internal fun MarketTokenDetailsChart(
state: MarketsTokenDetailsUM.ChartState,
backgroundColor: Color,
modifier: Modifier = Modifier,
) {
val growingColor = TangemTheme.colors.icon.accent
val fallingColor = TangemTheme.colors.icon.warning
val neutralColor = TangemTheme.colors.icon.informative
val chartState = rememberMarketChartState(
dataProducer = state.dataProducer,
colorMapper = { chartType ->
when (chartType) {
MarketChartLook.Type.Growing -> growingColor
MarketChartLook.Type.Falling -> fallingColor
MarketChartLook.Type.Neutral -> neutralColor
}
},
onMarkerShown = state.onMarkerPointSelected,
)
val bottomChartAxisHeight = getMarketChartBottomAxisHeight()
Box(modifier) {
MarketChart(
modifier = Modifier.fillMaxWidth(),
state = chartState,
)
if (state.status != MarketsTokenDetailsUM.ChartState.Status.DATA) {
Box(
Modifier
.drawBehind { drawRect(backgroundColor) }
.matchParentSize()
.padding(bottom = bottomChartAxisHeight),
) {
when (state.status) {
MarketsTokenDetailsUM.ChartState.Status.LOADING -> {
CircularProgressIndicator(
modifier = Modifier
.size(TangemTheme.dimens.size16)
.align(Alignment.Center),
color = TangemTheme.colors.text.accent,
strokeWidth = TangemTheme.dimens.size2,
)
}
MarketsTokenDetailsUM.ChartState.Status.ERROR -> {
UnableToLoadData(
modifier = Modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
)
.align(Alignment.Center),
onRetryClick = state.onLoadRetryClick,
)
}
else -> {}
}
}
}
}
}

View file

@ -1,168 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.TextButton
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.GridItems
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
import com.tangem.core.ui.extensions.resourceReference
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.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.InfoPointUM
import com.tangem.features.markets.details.impl.ui.state.MetricsUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
const val MAX_METRICS_COUNT = 6
@Composable
internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) {
var isExpanded by remember { mutableStateOf(false) }
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_metrics),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
if (state.metrics.size > MAX_METRICS_COUNT) {
ShowLessMoreButton(expanded = isExpanded, onClick = { isExpanded = !isExpanded })
}
},
content = {
val metrics = if (isExpanded) {
state.metrics
} else {
state.metrics.take(MAX_METRICS_COUNT).toImmutableList()
}
GridItems(
items = metrics,
itemContent = {
InfoPoint(infoPointUM = it)
},
)
},
)
}
// TODO make TextButton clickable area smaller and remove paddings for an action in InformationBlock
@Composable
private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) {
// FIXME add string resources
val text = if (expanded) {
"See less"
} else {
"See more"
}
TextButton(
text = text,
onClick = onClick,
colors = TangemButtonsDefaults.positiveButtonColors,
textStyle = TangemTheme.typography.body2,
)
}
@Composable
internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) {
InformationBlock(
modifier = modifier,
title = {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
style = TangemTheme.typography.subtitle2,
)
},
action = {
Box(Modifier)
},
content = {
GridItems(
items = List(size = 6) { it }.toImmutableList(),
horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
itemContent = {
InfoPointShimmer(
modifier = Modifier.fillMaxWidth(),
withTooltip = true,
)
},
)
},
)
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun BlockPreview() {
TangemThemePreview {
MetricsBlock(
state = MetricsUM(
metrics = persistentListOf(
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_capitalization),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_market_rating),
value = "A",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_trading_volume),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_fully_diluted_valuation),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_circulating_supply),
value = "1.2T",
onInfoClick = {},
),
InfoPointUM(
title = resourceReference(R.string.markets_token_details_total_supply),
value = "1.2T",
onInfoClick = {},
),
),
),
)
}
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
actualContent = { BlockPreview() },
shimmerContent = { MetricsBlockPlaceholder() },
)
}
}

View file

@ -1,256 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.block.information.InformationBlock
import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons
import com.tangem.core.ui.components.progressbar.TangemLinearProgressIndicator
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemAnimations
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.getText
import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM
import com.tangem.features.markets.impl.R
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier = Modifier) {
var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) }
InformationBlock(
modifier = modifier,
title = {
Text(
text = stringResourceSafe(id = R.string.markets_token_details_price_performance),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
action = {
SegmentedButtons(
config = persistentListOf(
PriceChangeInterval.H24,
PriceChangeInterval.MONTH,
PriceChangeInterval.ALL_TIME,
),
initialSelectedItem = PriceChangeInterval.H24,
onClick = { interval ->
currentInterval = interval
state.onIntervalChanged(interval)
},
modifier = Modifier.width(IntrinsicSize.Min),
) { interval ->
Box(
Modifier
.fillMaxSize()
.align(Alignment.Center)
.padding(
horizontal = 14.dp,
vertical = TangemTheme.dimens.spacing4,
),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = interval.getText().resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
)
}
}
},
content = {
val value = when (currentInterval) {
PriceChangeInterval.H24 -> state.h24
PriceChangeInterval.MONTH -> state.month
PriceChangeInterval.ALL_TIME -> state.all
else -> error("")
}
Content(
modifier = Modifier.fillMaxWidth(),
state = value,
)
},
)
}
@Composable
private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifier) {
val animatedIndicatorFraction by TangemAnimations.horizontalIndicatorAsState(
targetFraction = state.indicatorFraction,
)
Column(
modifier = modifier
.padding(vertical = TangemTheme.dimens.spacing8),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResourceSafe(R.string.markets_token_details_low),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
SpacerW8()
Text(
text = stringResourceSafe(R.string.markets_token_details_high),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
TangemLinearProgressIndicator(
modifier = Modifier
.height(TangemTheme.dimens.size6)
.fillMaxWidth(),
progress = { animatedIndicatorFraction },
color = TangemTheme.colors.text.accent,
backgroundColor = TangemTheme.colors.background.tertiary,
strokeCap = StrokeCap.Round,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
Text(
modifier = Modifier.weight(1f),
text = state.low,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.weight(1f),
text = state.high,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.End,
)
}
}
}
@Composable
internal fun PricePerformanceBlockPlaceholder(modifier: Modifier = Modifier) {
val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() }
val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() }
val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4
InformationBlock(
modifier = modifier,
title = {
RectangleShimmer(
modifier = Modifier
.height(headerHeight)
.fillMaxWidth(),
radius = TangemTheme.dimens.radius3,
)
},
content = {
Column(
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing8),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.width(35.dp),
style = TangemTheme.typography.caption2,
)
SpacerW8()
TextShimmer(
modifier = Modifier.width(35.dp),
style = TangemTheme.typography.caption2,
)
}
RectangleShimmer(
modifier = Modifier
.height(TangemTheme.dimens.size6)
.fillMaxWidth(),
radius = 27.dp,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size56),
style = TangemTheme.typography.body1,
)
SpacerW8()
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size56),
style = TangemTheme.typography.body1,
)
}
}
},
)
}
@Preview
@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
PricePerformanceBlock(
modifier = Modifier,
state = PricePerformanceUM(
h24 = PricePerformanceUM.Value(
low = "\$38,5K",
high = "\$58,5K",
indicatorFraction = 0.5f,
),
month = PricePerformanceUM.Value(
low = "\$500,5K",
high = "\$5800,5K",
indicatorFraction = 0.8f,
),
all = PricePerformanceUM.Value(
low = "\$58,52",
high = "\$580,5M",
indicatorFraction = 0.2f,
),
onIntervalChanged = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaceholderPreview() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
PricePerformanceBlockPlaceholder()
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -1,96 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.annotation.FloatRange
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.impl.R
import kotlin.math.round
private const val STARS_COUNT = 5
@Composable
internal fun ScoreStarsBlock(
score: Float,
horizontalSpacing: Dp,
scoreTextStyle: TextStyle,
modifier: Modifier = Modifier,
) {
val rounded = score.roundTo1decimal()
val percentage = rounded / STARS_COUNT
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(horizontalSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = rounded.toString(),
style = scoreTextStyle,
color = TangemTheme.colors.text.primary1,
)
Stars(fraction = percentage)
}
}
@Suppress("MagicNumber")
@Composable
private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) {
val grayColor = TangemTheme.colors.icon.inactive
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(times = 5) { i ->
Box(
modifier = Modifier.size(TangemTheme.dimens.size16),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier
.requiredSize(16.dp)
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithCache {
onDrawWithContent {
val starFraction = ((fraction - i * 0.2) / 0.2).coerceIn(0.0, 1.0)
val starFractionFloat = starFraction
.toFloat()
.roundTo1decimal()
drawContent()
drawRect(
color = grayColor,
topLeft = Offset(x = size.width * starFractionFloat, y = 0f),
size = Size(size.width * (1 - starFractionFloat), size.height),
blendMode = BlendMode.SrcIn,
)
}
},
imageVector = ImageVector.vectorResource(R.drawable.ic_star_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
}
}
@Suppress("MagicNumber")
private fun Float.roundTo1decimal(): Float {
return round(this * 10) / 10
}

View file

@ -1,134 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.text.TooltipText
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM
import com.tangem.features.markets.impl.R
@Composable
internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.fillMaxWidth()
.heightIn(max = TangemTheme.dimens.size72)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1F)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
TooltipText(
text = resourceReference(R.string.markets_token_details_security_score),
onInfoClick = state.onInfoClick,
textStyle = TangemTheme.typography.subtitle2,
)
Text(
text = state.description.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ScoreStarsBlock(
score = state.score,
scoreTextStyle = TangemTheme.typography.body1,
horizontalSpacing = TangemTheme.dimens.spacing8,
)
}
}
@Composable
internal fun SecurityScoreBlockPlaceholder(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary)
.fillMaxWidth()
.heightIn(max = TangemTheme.dimens.size72)
.padding(all = TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier
.fillMaxWidth(fraction = 0.4f)
.padding(vertical = TangemTheme.dimens.spacing2)
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween,
) {
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.subtitle2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier.fillMaxWidth(),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
TextShimmer(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
style = TangemTheme.typography.body2,
textSizeHeight = true,
)
}
}
@Preview(widthDp = 328, showBackground = true)
@Preview(widthDp = 328, showBackground = true, locale = "ru")
@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreview {
SecurityScoreBlock(
state = SecurityScoreUM(
score = 3.5f,
description = stringReference("Based on 3 ratings"),
onInfoClick = {},
),
)
}
}
@Preview(widthDp = 328, showBackground = true)
@Preview(widthDp = 328, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewPlaceholder() {
TangemThemePreview {
PreviewShimmerContainer(
shimmerContent = {
SecurityScoreBlockPlaceholder(
modifier = Modifier.fillMaxWidth(),
)
},
actualContent = {
ContentPreview()
},
)
}
}

View file

@ -1,190 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEachIndexed
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.markets.details.impl.ui.preview.SecurityScorePreviewData
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
@Composable
internal fun SecurityScoreBottomSheet(config: TangemBottomSheetConfig) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
TangemBottomSheet<SecurityScoreBottomSheetContent>(
config = config,
addBottomInsets = false,
title = { TangemBottomSheetTitle(title = it.title) },
content = { content ->
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
Text(
text = content.description.resolveReference(),
style = TangemTheme.typography.body2.copy(
color = TangemTheme.colors.text.secondary,
),
)
SpacerH12()
content.providers.fastForEachIndexed { index, provider ->
DividerContainer(
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = content.providers.lastIndex,
addDefaultPadding = false,
)
.background(TangemTheme.colors.background.action),
showDivider = index != content.providers.lastIndex,
) {
SecurityScoreProviderRow(
providerUM = provider,
onLinkClick = { content.onProviderLinkClick(provider) },
)
}
}
SpacerH16()
SpacerH(bottomBarHeight)
}
},
)
}
@Composable
private fun SecurityScoreProviderRow(
providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM,
onLinkClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing12)
.heightIn(min = TangemTheme.dimens.size68),
verticalAlignment = Alignment.CenterVertically,
) {
SubcomposeAsyncImage(
modifier = Modifier
.size(size = TangemTheme.dimens.size40)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(context = LocalContext.current)
.data(providerUM.iconUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
error = { RectangleShimmer(radius = TangemTheme.dimens.radius8) },
contentDescription = null,
)
Column(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
Text(
text = providerUM.name,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
if (providerUM.lastAuditDate != null) {
Text(
text = providerUM.lastAuditDate,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
SpacerWMax()
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
modifier = Modifier.clickable(
enabled = providerUM.urlData != null,
indication = ripple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
onClick = onLinkClick,
),
) {
ScoreStarsBlock(
score = providerUM.score,
scoreTextStyle = TangemTheme.typography.body2,
horizontalSpacing = TangemTheme.dimens.spacing3,
)
UrlBlock(providerUM)
}
}
}
@Composable
private fun UrlBlock(providerUM: SecurityScoreBottomSheetContent.SecurityScoreProviderUM) {
val urlData = providerUM.urlData
val rootHost = urlData?.rootHost
if (urlData != null && rootHost != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
) {
Text(
text = urlData.rootHost,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
Icon(
modifier = Modifier
.size(TangemTheme.dimens.size16),
painter = painterResource(id = R.drawable.ic_arrow_top_right_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
)
}
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SecurityScoreBottomSheetPreview() {
TangemThemePreview {
SecurityScoreBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = SecurityScorePreviewData.bottomSheetContent,
),
)
}
}

View file

@ -1,198 +0,0 @@
package com.tangem.features.markets.details.impl.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.items.DescriptionItem
import com.tangem.core.ui.components.items.DescriptionPlaceholder
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM
import com.tangem.features.markets.impl.R
@Suppress("CanBeNonNullable")
internal fun LazyListScope.tokenMarketDetailsBody(
state: MarketsTokenDetailsUM.Body,
isAccountEnabled: Boolean,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
) {
when (state) {
MarketsTokenDetailsUM.Body.Loading -> {
item("description-loading") {
DescriptionPlaceholder(modifier = Modifier.blockPaddings())
}
if (portfolioBlock != null) {
item(key = "portfolio") {
portfolioBlock(Modifier.blockPaddings())
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
loadingInfoBlocks()
}
is MarketsTokenDetailsUM.Body.Content -> {
if (state.description != null) {
description(state.description)
}
if (portfolioBlock != null) {
item(key = "portfolio") {
portfolioBlock(Modifier.blockPaddings())
}
}
if (isAccountEnabled) {
aboutCoinHeader()
}
infoBlocksList(state.infoBlocks)
}
is MarketsTokenDetailsUM.Body.Error -> {
error(state)
}
MarketsTokenDetailsUM.Body.Nothing -> {
// Do nothing
}
}
}
private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) {
item("body-error") {
Box(Modifier.fillMaxWidth()) {
UnableToLoadData(
modifier = Modifier
.align(Alignment.Center)
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing40,
),
onRetryClick = state.onLoadRetryClick,
)
}
}
}
private fun LazyListScope.aboutCoinHeader() {
item("aboutCoinHeader") {
Text(
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing20,
),
text = stringResourceSafe(R.string.markets_about_coin_header),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h3,
)
}
}
private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) {
item("description") {
DescriptionItem(
modifier = Modifier.blockPaddings(),
description = description.shortDescription,
hasFullDescription = description.fullDescription != null,
onReadMoreClick = description.onReadMoreClick,
)
}
}
internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks) {
if (state.insights != null) {
item("insights") {
InsightsBlock(
modifier = Modifier.blockPaddings(),
state = state.insights,
)
}
}
if (state.securityScore != null) {
item("securityScore") {
SecurityScoreBlock(
modifier = Modifier.blockPaddings(),
state = state.securityScore,
)
}
}
if (state.metrics != null) {
item("metrics") {
MetricsBlock(
modifier = Modifier.blockPaddings(),
state = state.metrics,
)
}
}
if (state.pricePerformance != null) {
item("pricePerformance") {
PricePerformanceBlock(
modifier = Modifier.blockPaddings(),
state = state.pricePerformance,
)
}
}
item(key = "listedOn") {
ListedOnBlock(
state = state.listedOn,
modifier = Modifier.blockPaddings(),
)
}
if (state.links != null) {
item("links") {
LinksBlock(
modifier = Modifier.blockPaddings(),
state = state.links,
)
}
}
}
private fun LazyListScope.loadingInfoBlocks() {
item("insights-loading") {
InsightsBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("securityScore-loading") {
SecurityScoreBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("metrics-loading") {
MetricsBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("pricePerformance-loading") {
PricePerformanceBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item(key = "listedOn-loading") {
ListedOnBlockPlaceholder(modifier = Modifier.blockPaddings())
}
item("links-loading") {
LinksBlockPlaceholder(modifier = Modifier.blockPaddings())
}
}
@Composable
private fun Modifier.blockPaddings(): Modifier {
return this.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing12,
)
}

View file

@ -1,129 +0,0 @@
package com.tangem.features.markets.details.impl.ui.preview
import com.tangem.common.ui.charts.state.MarketChartDataProducer
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.markets.details.impl.ui.state.*
import kotlinx.collections.immutable.persistentListOf
internal object MarketsTokenDetailsPreview {
private val infoPoint = InfoPointUM(
title = stringReference("1"),
value = "2",
change = InfoPointUM.ChangeType.DOWN,
onInfoClick = {},
)
val loadingState = MarketsTokenDetailsUM(
tokenName = "Token Name",
priceText = "$0.00000000324",
dateTimeText = stringReference("Today"),
priceChangePercentText = "52.00%",
iconUrl = "",
priceChangeType = PriceChangeType.UP,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = MarketChartDataProducer.build { },
onLoadRetryClick = {},
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = { _, _ -> },
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = { },
body = MarketsTokenDetailsUM.Body.Loading,
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
isMarkerSet = false,
triggerPriceChange = consumedEvent(),
)
val contentState = MarketsTokenDetailsUM(
tokenName = "Token Name",
priceText = "$0.00000000324",
dateTimeText = stringReference("Today"),
priceChangePercentText = "52.00%",
iconUrl = "",
priceChangeType = PriceChangeType.UP,
chartState = MarketsTokenDetailsUM.ChartState(
dataProducer = MarketChartDataProducer.build { },
onLoadRetryClick = {},
status = MarketsTokenDetailsUM.ChartState.Status.LOADING,
onMarkerPointSelected = { _, _ -> },
),
selectedInterval = PriceChangeInterval.H24,
onSelectedIntervalChange = { },
body = MarketsTokenDetailsUM.Body.Content(
description = MarketsTokenDetailsUM.Description(
shortDescription = stringReference("markets_token_details_description_short"),
fullDescription = stringReference("markets_token_details_description_full"),
onReadMoreClick = {},
),
infoBlocks = MarketsTokenDetailsUM.InformationBlocks(
insights = InsightsUM(
h24Info = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
weekInfo = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
monthInfo = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
onInfoClick = {},
onIntervalChanged = {},
),
securityScore = SecurityScoreUM(
score = 2.3f,
description = stringReference("markets_token_details_security_score_description"),
onInfoClick = {},
),
metrics = MetricsUM(
metrics = persistentListOf(
infoPoint,
infoPoint,
infoPoint,
),
),
pricePerformance = PricePerformanceUM(
h24 = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
month = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
all = PricePerformanceUM.Value(
low = "1",
high = "2",
indicatorFraction = 0.3f,
),
onIntervalChanged = {},
),
listedOn = ListedOnUM.Empty,
links = null,
),
),
bottomSheetConfig = TangemBottomSheetConfig(
isShown = false,
onDismissRequest = {},
content = TangemBottomSheetConfigContent.Empty,
),
isMarkerSet = true,
triggerPriceChange = consumedEvent(),
)
}

View file

@ -1,60 +0,0 @@
package com.tangem.features.markets.details.impl.ui.preview
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.markets.details.impl.ui.state.SecurityScoreBottomSheetContent
internal object SecurityScorePreviewData {
val bottomSheetContent = SecurityScoreBottomSheetContent(
title = stringReference("Security score"),
description = stringReference(
"Security score of a token is a metric that assesses the " +
"security level of a blockchain or token based on various factors and is compiled from " +
"the sources listed below.",
),
providers = listOf(
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Moralis",
lastAuditDate = "21.10.2024",
score = 4.9F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://moralis.com/",
rootHost = "moralis.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Certik",
lastAuditDate = "10.07.2024",
score = 4.6F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://certik.com/",
rootHost = "certik.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "Cyberscope",
lastAuditDate = "25.06.2023",
score = 4.5F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://cyberscope.com/",
rootHost = "cyberscope.com",
),
iconUrl = "",
),
SecurityScoreBottomSheetContent.SecurityScoreProviderUM(
name = "TokenInsight",
lastAuditDate = "17.01.2022",
score = 4.0F,
urlData = SecurityScoreBottomSheetContent.SecurityScoreProviderUM.UrlData(
fullUrl = "https://tokeninsight.com/",
rootHost = "tokeninsight.com",
),
iconUrl = "",
),
),
onProviderLinkClick = {},
)
}

View file

@ -1,73 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import androidx.annotation.StringRes
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.plus
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.features.markets.impl.R
import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Exchanges bottom sheet content
*
[REDACTED_AUTHOR]
*/
internal sealed interface ExchangesBottomSheetContent : TangemBottomSheetConfigContent {
/** Title of bottom sheet. Like, app bar. */
@get:StringRes
val titleResId: Int
get() = R.string.markets_token_details_exchanges_title
/** Subtitle */
@get:StringRes
val subtitleResId: Int
get() = R.string.markets_token_details_exchange
/** Volume info */
@get:StringRes
val volumeReference: TextReference
get() = resourceReference(id = R.string.markets_token_details_volume) +
stringReference(value = " $DOT ") +
resourceReference(id = R.string.markets_selector_interval_24h_title)
/** Exchange items */
val exchangeItems: ImmutableList<TokenItemState>
/**
* Loading state
*
* @property exchangesCount count of exchanges
*/
data class Loading(val exchangesCount: Int) : ExchangesBottomSheetContent {
override val exchangeItems: ImmutableList<TokenItemState>
get() = List(size = exchangesCount) { index -> TokenItemState.Loading(id = "loading#$index") }
.toImmutableList()
}
/**
* Content state
*
* @property exchangeItems exchanges
*/
data class Content(
override val exchangeItems: ImmutableList<TokenItemState>,
) : ExchangesBottomSheetContent
/** Error state */
data class Error(
val onRetryClick: () -> Unit,
) : ExchangesBottomSheetContent {
override val exchangeItems: ImmutableList<TokenItemState> = persistentListOf()
@StringRes
val message: Int = R.string.markets_loading_error_title
}
}

View file

@ -1,13 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
internal data class InfoBottomSheetContent(
val title: TextReference,
val body: TextReference,
val generatedAINotificationUM: GeneratedAINotificationUM? = null,
) : TangemBottomSheetConfigContent {
data class GeneratedAINotificationUM(val onClick: () -> Unit)
}

View file

@ -1,14 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.core.ui.extensions.TextReference
internal data class InfoPointUM(
val title: TextReference,
val value: String,
val change: ChangeType? = null,
val onInfoClick: (() -> Unit)? = null,
) {
enum class ChangeType {
UP, DOWN
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.features.markets.details.impl.ui.state
import com.tangem.domain.markets.PriceChangeInterval
import kotlinx.collections.immutable.ImmutableList
internal data class InsightsUM(
val h24Info: ImmutableList<InfoPointUM>,
val weekInfo: ImmutableList<InfoPointUM>,
val monthInfo: ImmutableList<InfoPointUM>,
val onInfoClick: () -> Unit,
val onIntervalChanged: (PriceChangeInterval) -> Unit,
)

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