Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-07 16:50:31 +03:00
parent 24c7340558
commit 7a6ea23dd4
14 changed files with 816 additions and 59 deletions

View file

@ -315,6 +315,7 @@
<string name="common_hour">час</string>
<string name="common_import">Импортировать</string>
<string name="common_in_progress">В процессе</string>
<string name="common_insufficient_balance">Недостаточный баланс</string>
<string name="common_later">Позже</string>
<string name="common_learn_more">Узнать больше</string>
<string name="common_left">Осталось %1$s</string>
@ -337,6 +338,7 @@
<string name="common_nft">NFT</string>
<string name="common_no">Нет</string>
<string name="common_no_address">Нет адреса</string>
<string name="common_no_results">Нет результата</string>
<string name="common_not_added">Не добавлено</string>
<string name="common_not_available">Не доступно</string>
<string name="common_not_now">Не сейчас</string>
@ -484,6 +486,13 @@
<string name="domain_receive_assets_onboarding_description">Отправка средств в другой сети может повлечь потерю средств.</string>
<string name="domain_receive_assets_onboarding_network_name">%s сеть</string>
<string name="domain_receive_assets_onboarding_title">Отправляйте средства, используя только</string>
<string name="dynamic_addresses">Динамические адреса</string>
<string name="dynamic_addresses_enter_features_privacy_description">Используйте новый адрес для каждой транзакции, чтобы снизить отслеживаемость и повысить конфиденциальность в блокчейне.</string>
<string name="dynamic_addresses_enter_features_privacy_title">Повышенная конфиденциальность</string>
<string name="dynamic_addresses_enter_features_receving_title">Бесшовное получение</string>
<string name="dynamic_addresses_enter_main_button_title">Включить динамические адреса</string>
<string name="dynamic_addresses_error_has_custom_token_title">Динамические адреса недоступны</string>
<string name="dynamic_addresses_error_service_unavailable_title">Сервис недоступен. Пожалуйста, попробуйте еще раз.</string>
<string name="earn_best_opportunities">Лучшие возможности</string>
<string name="earn_clear_filter">Очистить фильтр</string>
<string name="earn_empty">Список временно пуст — он обновляется. Пожалуйста, зайдите чуть позже.</string>
@ -701,6 +710,7 @@
<string name="initial_message_scan_header">Приложите, чтобы отсканировать</string>
<string name="initial_message_sign_header">Приложите, чтобы подписать</string>
<string name="initial_message_tap_header">Приложите карту или кольцо</string>
<string name="initial_wallet_sync_banner_title">Кошелёк успешно импортирован</string>
<string name="key_invalidated_warning_description">Вы обновили данные биометрии, отсканируйте свою карту или кольцо для входа</string>
<string name="koinos_insufficient_balance_to_send_koin_description">Ваш баланс должен быть выше суммы комиссии для осуществления перевода</string>
<string name="koinos_insufficient_balance_to_send_koin_title">Недостаточно средств</string>
@ -756,10 +766,14 @@
<string name="markets_loading_no_data_title">Нет данных</string>
<string name="markets_pulse_common_title">Пульс рынка</string>
<string name="markets_quick_actions">Быстрые действия</string>
<string name="markets_search_clear_all_hints">Очистить всё</string>
<string name="markets_search_header_title">Поиск токенов</string>
<string name="markets_search_hint_header">Недавние</string>
<string name="markets_search_portfolio_header">В вашем портфеле</string>
<string name="markets_search_result_title">Результат</string>
<string name="markets_search_see_tokens_under_100k">Токены с капитализацией меньше 100к USD</string>
<string name="markets_search_show_tokens">Показать токены</string>
<string name="markets_search_title_placeholder">Криптовалюты, новости и не только</string>
<string name="markets_search_token_no_result_title">Нет результата</string>
<string name="markets_select_network">Выберите сеть</string>
<string name="markets_select_wallet">Выберите кошелек</string>
@ -817,6 +831,10 @@
<string name="markets_token_details_holders">Держатели</string>
<string name="markets_token_details_holders_description">Изменение количества держателей токенов в течение выбранного периода времени</string>
<string name="markets_token_details_holders_full">Держатели</string>
<string name="markets_token_details_insight_day_timeline">Д</string>
<string name="markets_token_details_insight_month_timeline">М</string>
<string name="markets_token_details_insight_week_timeline">Н</string>
<string name="markets_token_details_insight_year_timeline">Г</string>
<string name="markets_token_details_insights">Инсайты</string>
<string name="markets_token_details_links">Ссылки</string>
<string name="markets_token_details_liquidity">Ликвидность</string>
@ -835,6 +853,7 @@
<string name="markets_token_details_max_supply_description">Максимальное количество монет или токенов, которое может когда-либо существовать для выбранной криптовалюты</string>
<string name="markets_token_details_max_supply_full">Максимальный объем</string>
<string name="markets_token_details_metrics">Метрики</string>
<string name="markets_token_details_metrics_no_limited">Нет ограничений</string>
<string name="markets_token_details_official_links">Официальные ссылки</string>
<string name="markets_token_details_price_performance">Динамика цены</string>
<string name="markets_token_details_repository">Репозиторий</string>
@ -1121,6 +1140,14 @@
<string name="qr_scanner_camera_denied_settings_button">Настройки</string>
<string name="qr_scanner_camera_denied_text">Вы не предоставили доступ к вашей камере</string>
<string name="qr_scanner_camera_denied_title">Доступ к камере запрещен</string>
<string name="qr_scanner_error_token_not_added_message">Запрашиваемый токен не добавлен в ваш кошелёк. Пожалуйста, добавьте его и попробуйте снова.</string>
<string name="qr_scanner_error_token_not_added_title">Токен не добавлен</string>
<string name="qr_scanner_error_unrecognized_message">Извините, этот QR-код не удалось распознать.</string>
<string name="qr_scanner_error_unrecognized_title">Нераспознанный QR-код</string>
<string name="qr_scanner_error_unsupported_network_message">Эта сеть не поддерживается ни одним из добавленных вами токенов. Добавьте поддерживаемый токен для отправки криптовалюты.</string>
<string name="qr_scanner_error_unsupported_network_title">Поддерживаемые токены не найдены</string>
<string name="qr_scanner_warning_unknown_parameters_message">Этот QR-код содержит параметры, которые не распознаны: %s. Некоторые данные платежа могут быть утеряны, если вы продолжите.</string>
<string name="qr_scanner_warning_unknown_parameters_title">Неизвестные параметры</string>
<string name="receive_bottom_sheet_no_memo_required_message">Memo не требуется</string>
<string name="receive_bottom_sheet_warning_message">%1$s (%2$s) в сети %3$s</string>
<string name="receive_bottom_sheet_warning_message_compact">%1$s в %2$s сети</string>
@ -1239,6 +1266,12 @@
<string name="send_memo">Мемо %s</string>
<string name="send_memo_destination_tag_error">Недопустимый Memo</string>
<string name="send_network_fee_warning_title">Покрытие сетевой комиссии</string>
<plurals name="send_network_selection_hidden_tokens">
<item quantity="one">Токен %d не совместим с этим адресом</item>
<item quantity="few"></item>
<item quantity="many">Токены %d не совместимы с этим адресом</item>
<item quantity="other"></item>
</plurals>
<string name="send_nonce">Nonce</string>
<string name="send_nonce_footer">Уникальный номер транзакции. Измените, чтобы ускорить отправку или отменить зависшую операцию.</string>
<string name="send_nonce_hint">Введите nonce…</string>
@ -1309,6 +1342,7 @@
<string name="send_with_swap_recipient_get_amount">К получению %s</string>
<string name="send_with_swap_remove_convert_alert_message">Вы уверены, что хотите отменить конвертацию? Ваши предыдущие данные будут удалены.</string>
<string name="send_with_swap_remove_convert_alert_title">Убрать конвертацию</string>
<string name="send_with_swap_something_went_wrong">Что-то пошло не так. Попробуйте еще раз.</string>
<string name="send_with_swap_title">Отправка с обменом</string>
<string name="sent_transaction_sent_title">Транзакция отправлена</string>
<string name="settings_card_settings_footer">Подготовьтесь к сканированию кольца или карты, которую вы хотите настроить.</string>
@ -1497,14 +1531,18 @@
<string name="swapping_fee_estimation_error_text">Ошибка расчета комиссии. Пожалуйста, отправьте информацию в поддержку.</string>
<string name="swapping_from_title">Вы отправляете</string>
<string name="swapping_high_price_impact_description">Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму.</string>
<string name="swapping_high_price_impact_text">Вы можете получить значительно меньше из-за низкой ликвидности. Попробуйте меньшую сумму или другого провайдера.</string>
<string name="swapping_high_price_impact_title">Высокое влияние на цену</string>
<string name="swapping_insufficient_funds">Недостаточно средств</string>
<string name="swapping_insufficient_funds_description">Недостаточно средств для завершения этой транзакции. Уменьшите сумму для получения или добавьте больше средств.</string>
<string name="swapping_permission_header">Дать разрешение</string>
<string name="swapping_swap_action">Обменять</string>
<string name="swapping_swap_action_in_progress">Обмен…</string>
<string name="swapping_to_title">Вы получите</string>
<string name="swapping_token_list_title">Выберите токен</string>
<string name="swapping_token_not_available">недоступен</string>
<string name="swapping_trade_too_large_text">Недостаточно ликвидности для этой сделки.\nУменьшите сумму или выберите другого провайдера.</string>
<string name="swapping_trade_too_large_title">Сумма сделки слишком велика</string>
<string name="tangem_pay_beta_notification_subtitle">Будем рады вашей обратной связи</string>
<string name="tangem_pay_beta_notification_title">Tangem Pay в режиме beta</string>
<string name="tangem_pay_card_frozen">Карта заморожена</string>

View file

@ -765,11 +765,13 @@
<string name="markets_insights_info_description_message">This sections data is sourced from the following networks: %s</string>
<string name="markets_loading_error_title">Unable to load the data…</string>
<string name="markets_loading_no_data_title">No data</string>
<string name="markets_portfolio_block_add_token_title">**Add to your portfolio** to start buying, exchanging or receiving this asset</string>
<string name="markets_portfolio_block_subtitle">In your portfolio</string>
<string name="markets_pulse_common_title">Market Pulse</string>
<string name="markets_quick_actions">Quick actions</string>
<string name="markets_search_clear_all_hints">Clear all</string>
<string name="markets_search_header_title">Search tokens</string>
<string name="markets_search_hint_header">Recents</string>
<string name="markets_search_hint_header">Recent\'s</string>
<string name="markets_search_portfolio_header">In your portfolio</string>
<string name="markets_search_result_title">Result</string>
<string name="markets_search_see_tokens_under_100k">See tokens under 100k USD market cap</string>

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import org.intellij.markdown.MarkdownElementTypes
@ -26,18 +27,23 @@ fun rememberMarkdownParser() = remember {
* @param node current processed node
*/
@Composable
fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode): AnnotatedString.Builder {
fun AnnotatedString.Builder.appendMarkdown(
markdownText: String,
node: ASTNode,
boldColor: Color = Color.Unspecified,
): AnnotatedString.Builder {
when (node.type) {
MarkdownElementTypes.MARKDOWN_FILE, MarkdownElementTypes.PARAGRAPH -> {
node.children.forEach { childNode ->
appendMarkdown(
markdownText = markdownText,
node = childNode,
boldColor = boldColor,
)
}
}
MarkdownElementTypes.STRONG -> {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
withStyle(SpanStyle(fontWeight = FontWeight.Medium, color = boldColor)) {
node.children
.drop(2)
.dropLast(2)
@ -45,6 +51,7 @@ fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode):
appendMarkdown(
markdownText = markdownText,
node = childNode,
boldColor = boldColor,
)
}
}
@ -56,6 +63,19 @@ fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode):
return this
}
/**
* Parses [rawString] as markdown and returns an [AnnotatedString] where bold (`**...**`) sections
* are styled with [boldColor].
*/
@Composable
fun formatAnnotatedWithBoldColor(rawString: String, boldColor: Color): AnnotatedString {
val markdownParser = rememberMarkdownParser()
val parsedTree = markdownParser.parse(MarkdownElementTypes.MARKDOWN_FILE, rawString, true)
return buildAnnotatedString {
appendMarkdown(markdownText = rawString, node = parsedTree, boldColor = boldColor)
}
}
/**
* Appends a single space character to the [AnnotatedString.Builder].
*/

View file

@ -0,0 +1,14 @@
package com.tangem.features.feed.components.portfolio
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
interface PortfolioComponent : ComposableContentComponent {
data class Params(
val id: CryptoCurrency.ID,
)
interface Factory : ComponentFactory<Params, PortfolioComponent>
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.feed.DefaultFeedComponent
@ -12,6 +13,7 @@ import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent
import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent
import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
@ -22,12 +24,15 @@ import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
@Suppress("LongParameterList")
internal class FeedEntryChildFactory @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val portfolioComponentFactory: MarketsPortfolioComponent.Factory,
private val portfolioBlockComponentFactory: PortfolioBlockComponent.Factory,
private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
private val designFeatureToggles: DesignFeatureToggles,
) {
@Serializable
@ -77,6 +82,8 @@ internal class FeedEntryChildFactory @Inject constructor(
params = child.params,
analyticsEventHandler = analyticsEventHandler,
portfolioComponentFactory = portfolioComponentFactory,
portfolioBlockComponentFactory = portfolioBlockComponentFactory,
designFeatureToggles = designFeatureToggles,
)
}
is Child.TokenList -> {

View file

@ -21,6 +21,7 @@ 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.DesignFeatureToggles
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem
@ -35,6 +36,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.feed.model.market.details.state.TokenNetworksState
@ -49,7 +51,9 @@ internal class DefaultMarketsTokenDetailsComponent(
appComponentContext: AppComponentContext,
val params: Params,
analyticsEventHandler: AnalyticsEventHandler,
designFeatureToggles: DesignFeatureToggles,
portfolioComponentFactory: MarketsPortfolioComponent.Factory,
portfolioBlockComponentFactory: PortfolioBlockComponent.Factory,
) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext {
// applying l2 compatibility
@ -61,25 +65,41 @@ internal class DefaultMarketsTokenDetailsComponent(
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
}
private val portfolioComponent: MarketsPortfolioComponent? =
if (updatedParams.shouldShowPortfolio && !designFeatureToggles.isRedesignEnabled) {
portfolioComponentFactory.create(
context = child("my_portfolio"),
params = MarketsPortfolioComponent.Params(
updatedParams.token,
analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) },
),
)
} else {
null
}
private val portfolioBlockComponent: PortfolioBlockComponent? =
if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) {
portfolioBlockComponentFactory.create(
context = child("portfolio_block"),
params = PortfolioBlockComponent.Params(updatedParams.token),
)
} else {
null
}
init {
componentScope.launch(dispatchers.default) {
model.networksState.collectLatest { state ->
when (state) {
is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(state.networks)
TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable()
is TokenNetworksState.NetworksAvailable -> {
portfolioBlockComponent?.setTokenNetworks(state.networks)
portfolioComponent?.setTokenNetworks(state.networks)
}
TokenNetworksState.NoNetworksAvailable -> {
portfolioBlockComponent?.setNoNetworksAvailable()
portfolioComponent?.setNoNetworksAvailable()
}
else -> {}
}
}
@ -192,6 +212,11 @@ internal class DefaultMarketsTokenDetailsComponent(
component.Content(blockModifier)
}
},
portfolioFloatingBlock = portfolioBlockComponent?.let { component ->
{ blockModifier ->
component.Content(blockModifier)
}
},
)
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import kotlinx.serialization.Serializable
@Deprecated("Will be removed in favor of PortfolioComponent with redesigned UI")
@Stable
interface MarketsPortfolioComponent : ComposableContentComponent {

View file

@ -0,0 +1,115 @@
package com.tangem.features.feed.components.market.details.portfolioblock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
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.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockModel
import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockRoute
import com.tangem.features.feed.components.market.details.portfolioblock.ui.PortfolioBlock
import com.tangem.features.feed.components.portfolio.PortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.serialization.Serializable
@Stable
internal class PortfolioBlockComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: Params,
) : ComposableContentComponent, AppComponentContext by context {
private val portfolioComponentFactory: PortfolioComponent.Factory = object : PortfolioComponent.Factory {
override fun create(context: AppComponentContext, params: PortfolioComponent.Params): PortfolioComponent {
TODO("STUB. Will be implemented")
}
}
@Serializable
data class Params(
val token: TokenMarketParams,
)
private val model: PortfolioBlockModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = PortfolioBlockRoute.serializer(),
handleBackButton = true,
childFactory = ::createBottomSheetChild,
)
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
model.setTokenNetworks(networks)
}
fun setNoNetworksAvailable() {
model.setNoNetworksAvailable()
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
PortfolioBlock(modifier = modifier, state = state)
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnusedParameter")
private fun createBottomSheetChild(
config: PortfolioBlockRoute,
componentContext: ComponentContext,
): ComposableBottomSheetComponent {
val currencyId = model.cryptoCurrencyIdState.value ?: return ComposableBottomSheetComponent.EMPTY
val portfolioComponent = portfolioComponentFactory.create(
context = childByContext(componentContext),
params = PortfolioComponent.Params(id = currencyId),
)
return PortfolioBottomSheetWrapper(
portfolioComponent = portfolioComponent,
onDismiss = { model.bottomSheetNavigation.dismiss() },
)
}
private class PortfolioBottomSheetWrapper(
private val portfolioComponent: PortfolioComponent,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() = onDismiss()
@Composable
override fun BottomSheet() {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
),
) {
portfolioComponent.Content(modifier = Modifier)
}
}
}
@AssistedFactory
interface Factory {
fun create(context: AppComponentContext, params: Params): PortfolioBlockComponent
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.feed.components.market.details.portfolioblock.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
@Module
@InstallIn(ModelComponent::class)
internal interface PortfolioBlockModelModule {
@Binds
@IntoMap
@ClassKey(PortfolioBlockModel::class)
fun providePortfolioBlockModel(model: PortfolioBlockModel): Model
}

View file

@ -0,0 +1,188 @@
package com.tangem.features.feed.components.market.details.portfolioblock.model
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.SpanStyle
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network
import com.tangem.common.getTotalFiatAmount
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.components.market.details.portfolioblock.ui.state.PortfolioBlockUM
import com.tangem.features.feed.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
import java.math.BigDecimal
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@Stable
@ModelScoped
internal class PortfolioBlockModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val allAccountSupplier: MultiAccountStatusListSupplier,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
) : Model() {
val state: StateFlow<PortfolioBlockUM>
field = MutableStateFlow<PortfolioBlockUM>(PortfolioBlockUM.Loading)
val bottomSheetNavigation: SlotNavigation<PortfolioBlockRoute> = SlotNavigation()
val cryptoCurrencyIdState: StateFlow<CryptoCurrency.ID?>
field = MutableStateFlow(null)
private val params = paramsContainer.require<PortfolioBlockComponent.Params>()
private val currencyRawId: CryptoCurrency.RawID = params.token.id
private val tokenIcon: CurrencyIconState = CurrencyIconState.CoinIcon(
url = params.token.imageUrl,
fallbackResId = R.drawable.ic_custom_token_44,
isGrayscale = false,
shouldShowCustomBadge = false,
)
private val availableNetworks = MutableSharedFlow<List<TokenMarketInfo.Network>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
init {
combineData()
.onEach { state.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
fun setTokenNetworks(networks: List<TokenMarketInfo.Network>) {
availableNetworks.tryEmit(networks)
}
fun setNoNetworksAvailable() {
availableNetworks.tryEmit(emptyList())
}
private fun combineData(): Flow<PortfolioBlockUM> {
return availableNetworks.transformLatest { networks ->
if (networks.isEmpty()) {
emit(PortfolioBlockUM.Hidden)
} else {
emitAll(portfolioFlow().distinctUntilChanged())
}
}.distinctUntilChanged()
}
private fun portfolioFlow(): Flow<PortfolioBlockUM> {
val portfolioDataFlow = allAccountSupplier().flatMapLatest { accountStatusLists ->
val walletFlows = accountStatusLists.map { accountStatusList ->
getUserWalletUseCase.invokeFlow(accountStatusList.userWalletId)
.mapNotNull { it.getOrNull() }
.map { wallet ->
WalletPortfolio(
userWallet = wallet,
currencies = accountStatusList.filterByRawId(),
)
}
}
if (walletFlows.isEmpty()) {
flowOf(emptyList())
} else {
combine(walletFlows) { it.toList() }
}
}.distinctUntilChanged()
val settingsFlow = combine(
getSelectedAppCurrencyUseCase.invokeOrDefault(),
getBalanceHidingSettingsUseCase.isBalanceHidden(),
) { appCurrency, isBalanceHidden ->
SettingsBox(appCurrency, isBalanceHidden)
}.distinctUntilChanged()
return combine(portfolioDataFlow, settingsFlow) { portfolios, settings ->
buildState(portfolios, settings)
}.distinctUntilChanged()
}
private fun AccountStatusList.filterByRawId(): List<CryptoCurrencyStatus> {
return accountStatuses.filterCryptoPortfolio().flatMap { accountStatus ->
accountStatus.tokenList.flattenCurrencies().filter { status ->
val statusRawId = status.currency.id.rawCurrencyId ?: return@filter false
getTokenIdIfL2Network(statusRawId.value) == currencyRawId.value
}
}
}
private fun buildState(portfolios: List<WalletPortfolio>, settings: SettingsBox): PortfolioBlockUM {
val allCurrencies = portfolios.flatMap { it.currencies }
val hasMultiCurrencyWallet = portfolios.any { it.userWallet.isMultiCurrency }
if (allCurrencies.isEmpty()) {
return if (hasMultiCurrencyWallet) {
PortfolioBlockUM.AddToken(
tokenIcon = tokenIcon,
onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) },
)
} else {
PortfolioBlockUM.Hidden
}
}
cryptoCurrencyIdState.update {
it ?: allCurrencies.first().currency.id
}
val totalFiat = allCurrencies.mapNotNull { it.getTotalFiatAmount() }
.fold(BigDecimal.ZERO, BigDecimal::add)
val formattedBalance = totalFiat.formatStyled {
fiat(
fiatCurrencyCode = settings.appCurrency.code,
fiatCurrencySymbol = settings.appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
}
return PortfolioBlockUM.Content(
totalBalance = formattedBalance,
tokensInPortfolioCount = allCurrencies.size,
tokenIcon = tokenIcon,
tokenName = allCurrencies.first().currency.name,
tokenSymbol = allCurrencies.first().currency.symbol,
isBalanceHidden = settings.isBalanceHidden,
onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) },
)
}
}
private data class WalletPortfolio(
val userWallet: UserWallet,
val currencies: List<CryptoCurrencyStatus>,
)
private data class SettingsBox(
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
)

View file

@ -0,0 +1,7 @@
package com.tangem.features.feed.components.market.details.portfolioblock.model
import com.tangem.core.decompose.navigation.Route
import kotlinx.serialization.Serializable
@Serializable
internal data object PortfolioBlockRoute : Route

View file

@ -0,0 +1,266 @@
package com.tangem.features.feed.components.market.details.portfolioblock.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
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.BottomFade
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.formatAnnotatedWithBoldColor
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.feed.components.market.details.portfolioblock.ui.state.PortfolioBlockUM
import com.tangem.features.feed.impl.R
@Composable
internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifier) {
val isVisible = state is PortfolioBlockUM.AddToken || state is PortfolioBlockUM.Content
val screenHeightPx = with(LocalDensity.current) { LocalWindowSize.current.height.toPx() }
Box(modifier = modifier) {
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
) {
BottomFade(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
}
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)),
exit = fadeOut(animationSpec = tween(durationMillis = 300)),
) {
val slideOffset by transition.animateFloat(
transitionSpec = {
tween(
durationMillis = 600,
easing = CubicBezierEasing(a = 0.83f, b = 0f, c = 0.17f, d = 1f),
)
},
label = "slideOffset",
) { enterExitState ->
when (enterExitState) {
EnterExitState.PreEnter -> screenHeightPx
EnterExitState.Visible -> 0f
EnterExitState.PostExit -> 0f
}
}
Box(modifier = Modifier.graphicsLayer { translationY = slideOffset }) {
when (state) {
is PortfolioBlockUM.AddToken -> AddTokenBlock(state)
is PortfolioBlockUM.Content -> ContentBlock(state)
is PortfolioBlockUM.Hidden,
is PortfolioBlockUM.Loading,
-> Unit
}
}
}
}
}
@Composable
private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = Modifier) {
FloatingCard(
modifier = modifier,
onClick = state.onClick,
) {
TangemRowContainer(
contentPadding = PaddingValues(0.dp),
) {
CurrencyIcon(
modifier = Modifier
.padding(end = TangemTheme.dimens2.x3)
.layoutId(TangemRowLayoutId.HEAD),
state = state.tokenIcon,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = state.tokenName,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = stringResourceSafe(R.string.markets_portfolio_block_subtitle),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP),
text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = TangemTheme.colors2.text.neutral.primary,
style = TangemTheme.typography2.bodyMedium16,
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM),
text = state.tokenSymbol,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
maxLines = 1,
)
TangemButton(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x3)
.layoutId(TangemRowLayoutId.TAIL),
buttonUM = TangemButtonUM(
type = TangemButtonType.Secondary,
iconRes = R.drawable.ic_chevron_24,
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X9,
onClick = state.onClick,
),
)
}
}
}
@Composable
private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = Modifier) {
FloatingCard(
modifier = modifier,
onClick = state.onClick,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CurrencyIcon(state.tokenIcon)
SpacerW(TangemTheme.dimens2.x3)
Text(
text = formatAnnotatedWithBoldColor(
rawString = stringResourceSafe(R.string.markets_portfolio_block_add_token_title),
boldColor = TangemTheme.colors2.text.neutral.primary,
),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
modifier = Modifier.weight(1f),
maxLines = 2,
)
SpacerW(TangemTheme.dimens2.x2)
TangemButton(
buttonUM = TangemButtonUM(
type = TangemButtonType.Secondary,
text = resourceReference(R.string.common_add),
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X9,
onClick = state.onClick,
),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FloatingCard(modifier: Modifier = Modifier, onClick: () -> Unit = {}, content: @Composable () -> Unit) {
Box(
modifier = modifier
.navigationBarsPadding()
.fillMaxWidth()
.widthIn(max = BottomSheetDefaults.SheetMaxWidth)
.padding(
start = TangemTheme.dimens2.x2,
end = TangemTheme.dimens2.x2,
bottom = TangemTheme.dimens2.x2,
)
.clip(RoundedCornerShape(size = TangemTheme.dimens2.x5))
.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(size = TangemTheme.dimens2.x5),
)
.clickable(onClick = onClick)
.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3),
) {
content()
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
TangemThemePreviewRedesign {
PortfolioBlock(
state = PortfolioBlockUM.Content(
totalBalance = stringReference("$1,234.56"),
tokensInPortfolioCount = 3,
tokenIcon = previewCoinIcon,
tokenName = "Bitcoin",
tokenSymbol = "BTC",
isBalanceHidden = false,
onClick = {},
),
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun AddTokenPreview() {
TangemThemePreviewRedesign {
PortfolioBlock(
state = PortfolioBlockUM.AddToken(
tokenIcon = previewCoinIcon,
onClick = {},
),
)
}
}
private val previewCoinIcon
get() = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22,
isGrayscale = false,
shouldShowCustomBadge = false,
)

View file

@ -0,0 +1,27 @@
package com.tangem.features.feed.components.market.details.portfolioblock.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal sealed class PortfolioBlockUM {
data object Loading : PortfolioBlockUM()
data object Hidden : PortfolioBlockUM()
data class AddToken(
val tokenIcon: CurrencyIconState,
val onClick: () -> Unit,
) : PortfolioBlockUM()
data class Content(
val totalBalance: TextReference,
val tokensInPortfolioCount: Int,
val tokenIcon: CurrencyIconState,
val tokenName: String,
val tokenSymbol: String,
val isBalanceHidden: Boolean,
val onClick: () -> Unit,
) : PortfolioBlockUM()
}

View file

@ -12,16 +12,22 @@ import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.onSizeChanged
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.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerH4
@ -64,6 +70,7 @@ internal fun MarketsTokenDetailsContent(
backgroundColor: Color,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
portfolioFloatingBlock: @Composable ((Modifier) -> Unit)?,
) {
Content(
contentPadding = contentPadding,
@ -71,6 +78,7 @@ internal fun MarketsTokenDetailsContent(
backgroundColor = backgroundColor,
state = state,
portfolioBlock = portfolioBlock,
portfolioFloatingBlock = portfolioFloatingBlock,
)
when (state.bottomSheetConfig.content) {
@ -80,7 +88,7 @@ internal fun MarketsTokenDetailsContent(
}
}
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
@Composable
private fun Content(
contentPadding: PaddingValues,
@ -88,6 +96,7 @@ private fun Content(
backgroundColor: Color,
modifier: Modifier = Modifier,
portfolioBlock: @Composable ((Modifier) -> Unit)?,
portfolioFloatingBlock: @Composable ((Modifier) -> Unit)?,
) {
val isRedesignEnabled = LocalRedesignEnabled.current
val density = LocalDensity.current
@ -97,54 +106,71 @@ private fun Content(
lazyListState = lazyListState,
onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange,
)
var bottomSpacing by remember { mutableStateOf(0.dp) }
Column(
modifier = modifier
Box(
modifier
.drawBehind { drawRect(backgroundColor) }
.fillMaxSize(),
) {
SpacerH4()
Column {
SpacerH4()
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()),
) {
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() }
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()),
) {
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,
portfolioBlock = portfolioBlock,
relatedNews = state.relatedNews,
isRedesignEnabled = isRedesignEnabled,
)
tokenMarketDetailsBody(
state = state.body,
portfolioBlock = portfolioBlock,
relatedNews = state.relatedNews,
isRedesignEnabled = isRedesignEnabled,
)
item { SpacerH(bottomSpacing) }
}
}
portfolioFloatingBlock?.invoke(
Modifier
.onSizeChanged { size ->
bottomSpacing = if (size.height > 0) {
with(density) { size.height.toDp() + 16.dp }
} else {
0.dp
}
}
.align(Alignment.BottomCenter),
)
}
}
@ -404,6 +430,7 @@ private fun MarketsTokenDetailsContent_Preview(
state = params,
backgroundColor = TangemTheme.colors.background.tertiary,
portfolioBlock = {},
portfolioFloatingBlock = null,
contentPadding = PaddingValues(),
)
}