Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-15 10:48:01 +01:00
parent b0ac045d0d
commit 626d0914a0
31 changed files with 1201 additions and 95 deletions

View file

@ -37,6 +37,14 @@ object MarketsDomainModule {
return GetMarketsTokenListFlowUseCase(marketsTokenRepository = marketsTokenRepository)
}
@Provides
@Singleton
fun provideGetTopFiveMarketTokenUseCase(
marketsTokenRepository: MarketsTokenRepository,
): GetTopFiveMarketTokenUseCase {
return GetTopFiveMarketTokenUseCase(marketsTokenRepository = marketsTokenRepository)
}
@Provides
@Singleton
fun provideGetTokenPriceChartUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenPriceChartUseCase {

1
common/ui-markets/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,29 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.common.ui.markets"
}
dependencies {
/** Project - Core */
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Project - Common */
implementation(projects.common.uiCharts)
implementation(projects.common.ui)
/** Project - Domain */
implementation(projects.domain.models)
implementation(deps.lifecycle.compose)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.ui.utils)
implementation(deps.kotlin.immutable.collections)
}

View file

@ -0,0 +1,39 @@
package com.tangem.common.ui.markets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
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.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@Composable
fun MarketChartLoadingError(onRetryClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(vertical = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.common_unable_to_load),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
SpacerH(12.dp)
SecondarySmallButton(
config = SmallButtonConfig(
text = TextReference.Res(R.string.try_to_load_data_again_button_title),
onClick = onRetryClick,
),
)
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.market.components
package com.tangem.common.ui.markets
import android.content.res.Configuration
import androidx.compose.foundation.background
@ -19,6 +19,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import com.tangem.common.ui.charts.MarketChartMini
import com.tangem.common.ui.charts.state.MarketChartLook
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider
import com.tangem.common.ui.tokens.TokenPriceText
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
@ -32,13 +34,11 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.core.ui.windowsize.WindowSizeType
import com.tangem.features.feed.ui.market.preview.MarketChartListItemPreviewDataProvider
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.utils.StringsSigns.MINUS
import kotlin.random.Random
@Composable
internal fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
fun MarketsListItem(model: MarketsListItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
MarketsListItemContent(
modifier = modifier
.fillMaxWidth()

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.market.components
package com.tangem.common.ui.markets
import android.content.res.Configuration
import androidx.compose.foundation.background

View file

@ -1,4 +1,4 @@
package com.tangem.features.feed.ui.market.state
package com.tangem.common.ui.markets.models
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.charts.state.MarketChartLook

View file

@ -1,15 +1,15 @@
package com.tangem.features.feed.ui.market.preview
package com.tangem.common.ui.markets.preview
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import kotlinx.collections.immutable.persistentListOf
@Suppress("MagicNumber")
internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider<MarketsListItemUM>(
class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider<MarketsListItemUM>(
collection = listOf(
MarketsListItemUM(
id = CryptoCurrency.RawID("1"),

View file

@ -22,6 +22,7 @@ import com.tangem.core.ui.components.block.TangemBlockCardColors
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.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
@ -87,7 +88,7 @@ private fun TrendingArticle(articleConfigUM: ArticleConfigUM) {
ArticleInfo(
score = articleConfigUM.score,
createdAt = articleConfigUM.createdAt,
createdAt = articleConfigUM.createdAt.resolveReference(),
)
SpacerH(32.dp)
@ -104,7 +105,7 @@ private fun DefaultArticle(articleConfigUM: ArticleConfigUM) {
Column(modifier = Modifier.padding(12.dp)) {
ArticleInfo(
score = articleConfigUM.score,
createdAt = articleConfigUM.createdAt,
createdAt = articleConfigUM.createdAt.resolveReference(),
)
SpacerH(8.dp)
@ -182,7 +183,7 @@ private fun ArticleCardsPreview() {
id = 1,
title = "Bitcoin ETFs log 4th straight day of inflows (+\$550M)",
score = 9.5f,
createdAt = "1h ago",
createdAt = TextReference.Str("1h ago"),
isTrending = true,
tags = tags,
isViewed = false,

View file

@ -1,13 +1,14 @@
package com.tangem.common.ui.news
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableSet
data class ArticleConfigUM(
val id: Int,
val title: String,
val score: Float,
val createdAt: String,
val createdAt: TextReference,
val isTrending: Boolean,
val tags: ImmutableSet<LabelUM>,
val isViewed: Boolean,

View file

@ -849,6 +849,9 @@
<string name="no_trustline_xlm_asset">Das Zielkonto verfügt nicht über eine Vertrauensstellung für das gesendete Asset.</string>
<string name="notification_black_friday_text">Sicher Dir 10 $ in BTC mit jeder Wallet greif schnell zu!</string>
<string name="notification_black_friday_title">Black Friday: bis zu 30% Nachlass</string>
<string name="notification_one_plus_one_button">Los geht\'s</string>
<string name="notification_one_plus_one_text">Nur für kurze Zeit!</string>
<string name="notification_one_plus_one_title">1+1: Beim Kauf einer Wallet erhältst Du 50% Rabatt</string>
<string name="notification_referral_promo_button">Jetzt beitreten</string>
<string name="notification_referral_promo_text">Teile Deinen Code verdiene 5 USDT pro Verkauf. Deine Freunde erhalten 10 % Rabatt.</string>
<string name="notification_referral_promo_title">Erhalte BELOHNUNGEN für jeden Freund!</string>

View file

@ -848,8 +848,8 @@
<string name="notification_black_friday_text">ウォレットごとに$10相当のBTCをプレゼント\nお早めに</string>
<string name="notification_black_friday_title">ブラックフライデー最大30% オフ</string>
<string name="notification_one_plus_one_button">今すぐチェック</string>
<string name="notification_one_plus_one_text">理想のTangemセットを揃えよう。期間限定。</string>
<string name="notification_one_plus_one_title">111つ購入で、2つ目が50%オフ</string>
<string name="notification_one_plus_one_text">期間限定!</string>
<string name="notification_one_plus_one_title">11ウォレット1つ購入で、2つ目が50%オフ</string>
<string name="notification_referral_promo_button">今すぐ参加</string>
<string name="notification_referral_promo_text">コードを共有すると、販売ごとに5 USDTを獲得できます。お友達は10%割引になります。</string>
<string name="notification_referral_promo_title">友達への紹介で報酬を獲得しよう!</string>
@ -1391,7 +1391,7 @@
<string name="tangem_pay_explore_transaction">取引を表示</string>
<string name="tangem_pay_fee_subtitle">サービス手数料</string>
<string name="tangem_pay_fee_title">手数料</string>
<string name="tangem_pay_freeze_card_alert_body">カードを紛失したり盗まれたりしても、資金を安全に保てます。一時停止はいつでも解除できます。</string>
<string name="tangem_pay_freeze_card_alert_body">資金を安全に保管します。凍結はいつでも解除できます。</string>
<string name="tangem_pay_freeze_card_alert_title">カードを一時停止しますか?</string>
<string name="tangem_pay_freeze_card_failed">カードを凍結できませんでした。しばらくしてからもう一度お試しください。</string>
<string name="tangem_pay_freeze_card_freeze">一時停止</string>

View file

@ -16,8 +16,10 @@
<string name="access_code_navtitle">Код доступа</string>
<string name="account_add_limit_dialog_description">Превышен лимит %1$s аккаунтов. Архивируйте, чтобы создать новый.</string>
<string name="account_add_limit_dialog_title">Невозможно добавить новый аккаунт.</string>
<string name="account_archive_success_message">Аккаунт заархивирован</string>
<string name="account_archived_accounts">Архивированные аккаунты</string>
<string name="account_archived_recover">Восстановить</string>
<string name="account_archived_recover_dialog_description">Вы собираетесь восстановить \"%1$s\".</string>
<string name="account_archived_recover_dialog_title">Восстановить аккаунт</string>
<string name="account_archived_recover_error_message">Лимит в 20 активных аккаунтов достигнут. Пожалуйста, зархивируйте один аккаунт, чтобы продолжить.</string>
<string name="account_archived_recover_error_title">Невозможно восстановить аккаунт</string>
@ -26,12 +28,14 @@
<string name="account_could_not_archive_referral_program_message">Этот аккаунт участвует в реферальной программе</string>
<string name="account_could_not_archive_referral_program_title">Этот аккаунт не может быть архивирован.</string>
<string name="account_could_not_create">Мы не смогли создать аккаунт. Пожалуйста, попробуйте позже.</string>
<string name="account_create_success_message">Аккаунт создан</string>
<string name="account_details_archive">Архивный аккаунт</string>
<string name="account_details_archive_action">Архив</string>
<string name="account_details_archive_description">Вы архивируете свой аккаунт, но в любое время можете вернуть его обратно</string>
<string name="account_details_archiving">Архивация...</string>
<string name="account_details_title">Аккаунт</string>
<string name="account_edit_failure_dialog_title">Невозможно отредактировать аккаунт</string>
<string name="account_edit_success_message">Аккаунт сохранен</string>
<string name="account_for_rewards">Аккаунт для наград</string>
<string name="account_form_create_button">Добавить аккаунт</string>
<string name="account_form_edit_button">Сохранить</string>
@ -46,6 +50,8 @@
<string name="account_main_account_title">Основной аккаунт</string>
<string name="account_recover_limit_dialog_description">Лимит %1$s активных аккаунтов превышен. Архивируйте один, чтобы продолжить.</string>
<string name="account_recover_limit_dialog_title">Невозможно восстановить аккаунт</string>
<string name="account_recover_success_message">Аккаунт восстановлен</string>
<string name="account_reorder_description">Нажмите и удерживайте аккаунт, чтобы изменить порядок аккаунтов.</string>
<string name="account_unsaved_dialog_action_first">Продолжить</string>
<string name="account_unsaved_dialog_action_second">Отменить</string>
<string name="account_unsaved_dialog_message_create">Вы уверены, что хотите создание нового аккаунта?</string>
@ -87,14 +93,17 @@
<string name="alert_manage_tokens_unsupported_message">Токены в сети %1$s не поддерживаются этой картой или кольцом из-за ограничений прошивки.</string>
<string name="alert_troubleshooting_scan_card_title">У вас возникли трудности со сканированием карты или кольца?</string>
<string name="alert_unsupported_card">Эта карта не предназначена для работы с этим приложением</string>
<string name="app_settings_access_code_warning">Сначала установите код доступа, чтобы включить биометрию.</string>
<string name="app_settings_biometrics_footer">Используйте %1$s, чтобы быстро и безопасно разблокировать кошелёк и выполнять чувствительные действия, например, подписывать транзакции. Для аппаратных кошельков всё ещё требуется карта или кольцо для подписи.</string>
<string name="app_settings_default_fee">Комиссия по-умолчанию</string>
<string name="app_settings_default_fee_footer">Подключите функцию комиссии по умолчанию и при формировании транзакции на отправку средств комиссия будет выставлена автоматически, а экран комиссии пропущен. Вы всегда сможете на него вернуться.</string>
<string name="app_settings_enable_biometrics_description">Перейдите в настройки, чтобы включить биометрическую аутентификацию в приложении Tangem</string>
<string name="app_settings_enable_biometrics_title">Включите биометрическую аутентификацию</string>
<string name="app_settings_off_biometrics_alert_message">Отключение %1$s потребует ввода вашего кода доступа для разблокировки приложения и работы с кошельком.</string>
<string name="app_settings_off_require_access_code_alert_message">Позже будет необходимо ввести код доступа к вашему кошельку, чтобы мы могли безопасно сохранить его для будущего использования.</string>
<string name="app_settings_off_saved_access_code_alert_message">Все сохраненные коды доступа будут удалены. Вам потребуется вводить код доступа при работе с кошельком.</string>
<string name="app_settings_off_saved_wallet_alert_message">При отключении функции сохранения кошелька все ранее сохраненные кошельки будут удалены из приложения.</string>
<string name="app_settings_on_require_access_code_alert_message">Это удалит все сохранённые коды доступа к кошелькам. Любое дальнейшее взаимодействие с кошельком будет требовать ввода кода доступа.</string>
<string name="app_settings_require_access_code">Требуется код доступа</string>
<string name="app_settings_require_access_code_footer">Эта опция отключает использование биометрии для выполнения чувствительных действий. Каждый раз, например при подписании транзакции, вам потребуется вводить код доступа.</string>
<string name="app_settings_saved_access_codes">Сохранение кода доступа</string>
@ -144,6 +153,7 @@
<string name="biometric_lockout_warning_description_2">Вход по биометрии временно заблокирован. Попробуйте снова через 30 секунд или разблокируйте кошелёк с помощью прикладывания устройства или кода доступа.</string>
<string name="biometric_lockout_warning_title">Слишком много попыток</string>
<string name="biometric_unavailable_warning">Вы отключили биометрическую аутентификацию на вашем телефоне и не сможете сохранять кошельки в приложении. Для сохранения кошельков, пожалуйста, включите функцию биометрической аутентификации в настройках телефона.</string>
<string name="biometric_updated_warning_description">Биометрические данные на вашем устройстве были обновлены. Пожалуйста, выберите свой кошелёк и введите его код доступа, чтобы повторно включить вход по биометрии.</string>
<string name="biometric_updated_warning_title">Внимание</string>
<string name="bitcoin_promo_activation_error">При обработке промокода произошла ошибка. Пожалуйста, попробуйте позже.</string>
<string name="bitcoin_promo_activation_error_title">Ошибка активации</string>
@ -299,6 +309,7 @@
<string name="common_later">Позже</string>
<string name="common_learn_more">Узнать больше</string>
<string name="common_left">Осталось %1$s</string>
<string name="common_legacy_bitcoin_address">Легаси Bitcoin</string>
<string name="common_locked">Заблокирован</string>
<string name="common_locked_wallets">Заблокированные кошельки</string>
<string name="common_main_network">Основная сеть</string>
@ -311,6 +322,8 @@
<item quantity="many">%d сетей</item>
<item quantity="other">%d сети</item>
</plurals>
<string name="common_new_address">Новый адрес</string>
<string name="common_news">Новости</string>
<string name="common_next">Далее</string>
<string name="common_nft">NFT</string>
<string name="common_no">Нет</string>
@ -363,6 +376,7 @@
<string name="common_support">Поддержка</string>
<string name="common_supported_networks">Поддерживаемые сети</string>
<string name="common_swap">Обменять</string>
<string name="common_tangem">Tangem</string>
<string name="common_terms_and_conditions">условия участия</string>
<string name="common_terms_of_use">Условиями использования</string>
<string name="common_to">На</string>
@ -391,6 +405,7 @@
<string name="contract_address_copied_message">Адрес контракта скопирован!</string>
<string name="currency_subtitle_expanded">Доступные сети</string>
<string name="custom_token_another_account_dialog_description">Деривация вашего токена совпадает с деривацией %1$s. Токен будет добавлен к этой учётной записи.</string>
<string name="custom_token_another_account_dialog_title">Деривация принадлежит другому аккаунту</string>
<string name="custom_token_another_account_snackbar_text">Токен добавлен в аккаунт %1$s</string>
<string name="custom_token_contract_address_input_title">Адрес контракта</string>
<string name="custom_token_creation_error_invalid_contract_address">Адрес контракта некорректен</string>
@ -527,6 +542,8 @@
<string name="express_transaction_id">ID: %s</string>
<string name="express_transaction_id_copied">ID транзакции скопирован</string>
<string name="exсhange_token_description">Обменяйте любой актив в вашем портфеле на этот токен</string>
<string name="feed_market_and_news">Рынок и Новости</string>
<string name="feed_trending_now">В тренде</string>
<string name="feedback_data_collection_message">Информация ниже не является обязательной. Вы можете стереть её, если хотите.</string>
<string name="feedback_preface_rate_negative">Расскажите, каких функций вам не хватает, и мы постараемся вам помочь.</string>
<string name="feedback_preface_scan_failed">Скажите, пожалуйста, какая у вас карта или кольцо?</string>
@ -687,6 +704,7 @@
<string name="markets_apy_placeholder">APY %s</string>
<string name="markets_common_my_portfolio">Мой портфель</string>
<string name="markets_common_title">Рынок</string>
<string name="markets_earn_common_title">Зарабатывай с Tangem</string>
<string name="markets_generate_addresses_notification">Чтобы создать адреса для выбранных сетей, отсканируйте вашу карту или кольцо Tangem кошелька</string>
<string name="markets_hint">Потяните вверх или коснитесь поисковой строки, чтобы добавить токен</string>
<string name="markets_insights_info_description_message">Данные раздела получены из следующих сетей: %s</string>
@ -791,6 +809,8 @@
<string name="markets_yield_supply_banner_title">Активировать режим доходности</string>
<string name="mobile_wallet_requires_min_os_warning_body">Обновитесь до версии %1$s, чтобы создать мобильный кошелёк</string>
<string name="mobile_wallet_requires_min_os_warning_title">Мобильный кошелек требует %1$s или новее.</string>
<string name="news_all_news">Все новости</string>
<string name="news_stay_in_the_loop">Будьте в курсе</string>
<string name="nfc_error_unavailable">Функция NFC недоступна на вашем устройстве</string>
<string name="nft_about_title">О NFT</string>
<string name="nft_asset">NFT</string>
@ -1198,6 +1218,7 @@
<string name="send_tron_account_activation_error">%1$s — это монета в сети Tron. Чтобы рассчитать комиссию и совершить транзакцию, вам необходимо внести немного Tron (TRX) на свой адрес.</string>
<string name="send_validation_amount_exceeds_balance">Сумма превышает остаток</string>
<string name="send_validation_destination_tag_required_description">Для завершения транзакции на указанный адрес необходим тег назначения (мемо).</string>
<string name="send_validation_destination_tag_required_title">Требуется Destination Tag</string>
<string name="send_validation_invalid_amount">Недопустимая сумма</string>
<string name="send_validation_invalid_fee">Комиссия превышает остаток</string>
<string name="send_validation_invalid_total">Отправляемая сумма превышает остаток</string>
@ -1225,6 +1246,8 @@
<string name="staking_account_initialization_footer">Сетевая комиссия — это небольшая плата за обработку и подтверждение вашей транзакции в блокчейне.</string>
<string name="staking_account_initialization_message">Чтобы начать стейкинг, ваш TON-аккаунт должен быть активирован транзакцией самому себе на 1 TON. Средства остаются в вашем кошельке — этот шаг лишь активирует ваш аккаунт для участия в стейкинге.</string>
<string name="staking_account_initialization_title">Активация аккаунта</string>
<string name="staking_alert_network_fee_updated_message">Сетевая комиссия изменилась. Пожалуйста, проверьте новую сумму перед продолжением.</string>
<string name="staking_alert_network_fee_updated_title">Комиссия сети обновлена</string>
<string name="staking_amount_requirement_error">Сумма для стейкинга должна быть не менее %s</string>
<string name="staking_amount_tron_integer_error">Согласно правилам сети, сумма стейкинга будет округлена до %1$sTRX.</string>
<string name="staking_amount_tron_integer_error_unstaking">Сумма для вывода из стейкинга будет округлена до %1$s TRX ввиду особенностей сети.</string>
@ -1263,6 +1286,7 @@
<string name="staking_give_permission_fee_footer">Комиссия сети за одобрение токена будет взиматься за подтверждение того, что именно вы разрешаете использовать ваш токен для стейкинга.</string>
<string name="staking_legal">Пользуясь стейкинг сервисом, вы соглашаетесь с %1$s и %2$s</string>
<string name="staking_locked">Заблокировано</string>
<string name="staking_max_amount_requirement_error">Максимальная сумму: %s</string>
<string name="staking_migrate">Переместить</string>
<string name="staking_native">Нативный стейкинг</string>
<string name="staking_no_validators_error_message">В данный момент нет доступных активных валидаторов для стейкинга. Пожалуйста, попробуйте позже.</string>
@ -1426,7 +1450,7 @@
<string name="tangem_pay_unfreeze_card_alert_title">Разморозить карту?</string>
<string name="tangem_pay_unfreeze_card_failed">Не удалось разморозить карту, попробуйте еще раз</string>
<string name="tangem_pay_unfreeze_card_success">Карта разморожена</string>
<string name="tangem_pay_withdrawal">Вывод</string>
<string name="tangem_pay_withdrawal">Вывести</string>
<string name="tangempay_card_details_add_funds">Пополнить</string>
<string name="tangempay_card_details_add_funds_subtitle">Способы пополнения</string>
<string name="tangempay_card_details_card_number">Номер</string>
@ -1462,7 +1486,7 @@
<string name="tangempay_card_details_swap_description">Пополните карту любым активом через обмен </string>
<string name="tangempay_card_details_title">Реквизиты</string>
<string name="tangempay_card_details_unfreeze_card">Разморозить карту</string>
<string name="tangempay_card_details_withdraw">Вывод</string>
<string name="tangempay_card_details_withdraw">Вывести</string>
<string name="tangempay_card_details_withdraw_error_title">Вывод сейчас недоступен</string>
<string name="tangempay_card_details_withdraw_in_progress_description">Вы не можете начать обмен или новый вывод, пока не завершится текущий.</string>
<string name="tangempay_card_details_withdraw_in_progress_title">Вывод выполняется</string>
@ -1598,6 +1622,7 @@
<string name="visa_onboarding_access_code_navigation_title">Код доступа</string>
<string name="visa_onboarding_account_activation_navigation_title">Активация аккаунта</string>
<string name="visa_onboarding_pin_not_accepted">ПИН не принят. Попробуйте ещё раз или введите другой код.</string>
<string name="visa_unlock_notification_button">Разблокировать</string>
<string name="wallet_add_common_title">Выберите способ добавления кошелька</string>
<string name="wallet_add_hardware_description">Отсканируйте вашу карту или кольцо Tangem, чтобы восстановить её или импортировать из другого кошелька.</string>
<string name="wallet_add_hardware_info_create">Создать аппаратный кошелёк</string>
@ -1934,6 +1959,7 @@
<string name="yield_module_fee_policy_sheet_title">Политика комиссий за пополнение</string>
<string name="yield_module_fee_policy_tangem_service_fee_title">Tangem взимает комиссию за обслуживание в размере 15% от полученного дохода.</string>
<string name="yield_module_high_fee_error">Ваши средства будут автоматически переведены в Aave, как только комиссия сети снизится или баланс достигнет минимально необходимой суммы.</string>
<string name="yield_module_high_network_fees_notification_title">Высокая сетевая комиссия</string>
<string name="yield_module_historical_returns">Историческая доходность</string>
<string name="yield_module_main_screen_promo_banner_message">Получай до %1$s APY на свой баланс</string>
<string name="yield_module_main_view_approve_notification_description">Разрешение для вашего токена в режиме доходности было отозвано. Откройте токен, чтобы выдать разрешение снова.</string>

View file

@ -584,30 +584,30 @@
<string name="hw_activation_need_title">Finalize wallet setup</string>
<string name="hw_activation_need_warning_description">Complete setup by securing the app with an access code.</string>
<string name="hw_backup_alert_description">If you do, you\'ll need to start over.</string>
<string name="hw_backup_alert_title">Are you sure you want to quit the activation process?</string>
<string name="hw_backup_banner_description">Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault.</string>
<string name="hw_backup_alert_title">Are you sure you want to quit the setup process?</string>
<string name="hw_backup_banner_description">Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault.</string>
<string name="hw_backup_close_description">If you do, you\'ll need to start over.</string>
<string name="hw_backup_google_drive_description">Recover existing wallet via Google Drive backup</string>
<string name="hw_backup_google_drive_title">Google Drive backup</string>
<string name="hw_backup_hardware_create_description">Create a new secure wallet and transfer your funds for extra protection.</string>
<string name="hw_backup_hardware_create_description">Create a secure wallet and transfer your funds for extra protection.</string>
<string name="hw_backup_hardware_create_title">Create new wallet</string>
<string name="hw_backup_hardware_description">Level up your security with the superior Tangem hardware wallet.</string>
<string name="hw_backup_hardware_title">Hardware Wallet</string>
<string name="hw_backup_hardware_upgrade_description">Move your current wallet into Tangem Wallet.</string>
<string name="hw_backup_hardware_upgrade_description">Move your current wallet into Tangem.</string>
<string name="hw_backup_hardware_upgrade_title">Upgrade current wallet</string>
<string name="hw_backup_need_action">Go to backup</string>
<string name="hw_backup_need_description">Please back up your wallet before creating an access code.</string>
<string name="hw_backup_need_finish_first">Finish Backup First</string>
<string name="hw_backup_need_finish_first">Finish backup first</string>
<string name="hw_backup_need_title">Finalize backup first</string>
<string name="hw_backup_no_backup">Incomplete</string>
<string name="hw_backup_section_other_title">Other methods</string>
<string name="hw_backup_seed_description">Manually save your recovery phrase in a secure place and keep it private to protect your funds.</string>
<string name="hw_backup_seed_description">Save your recovery phrase in a secure place and keep it private to protect your funds.</string>
<string name="hw_backup_seed_title">Recovery phrase</string>
<string name="hw_backup_to_secure_description">To secure your wallet with an Access Code, complete the backup first.</string>
<string name="hw_backup_to_upgrade_description">To upgrade your wallet to hardware, back it up first.</string>
<string name="hw_backup_to_upgrade_description">To upgrade to a hardware wallet, complete the backup process.</string>
<string name="hw_create_keys_description">Your private keys are securely encrypted and stored on your phone</string>
<string name="hw_create_keys_title">Keys are stored in the app</string>
<string name="hw_create_seed_description">Create or restore your wallet using a recovery phrase — your built-in backup.</string>
<string name="hw_create_keys_title">Private keys stay on your device</string>
<string name="hw_create_seed_description">Create or restore your wallet with a recovery phrase.</string>
<string name="hw_create_seed_title">Seed phrase backup</string>
<string name="hw_create_title">Create Mobile Wallet</string>
<string name="hw_import_existing_wallet">Import existing wallet</string>
@ -620,13 +620,13 @@
<string name="hw_remove_wallet_notification_action_backup_go">Go to backup</string>
<string name="hw_remove_wallet_notification_action_backup_view">View backup</string>
<string name="hw_remove_wallet_notification_action_forget">Forget wallet</string>
<string name="hw_remove_wallet_notification_action_forget_anyway">Forget Anyway</string>
<string name="hw_remove_wallet_notification_description_has_backup">A backup for this wallet exists. Review it before forgetting to make sure you can recover later.</string>
<string name="hw_remove_wallet_notification_description_without_backup">If you forget this wallet without a backup, youll permanently lose access to your funds.</string>
<string name="hw_remove_wallet_notification_title">Forget this wallet?</string>
<string name="hw_remove_wallet_notification_action_forget_anyway">Forget anyway</string>
<string name="hw_remove_wallet_notification_description_has_backup">This wallet has a backup. Ensure you can recover it before forgetting the wallet.</string>
<string name="hw_remove_wallet_notification_description_without_backup">If you forget this wallet without a backup, you\'ll permanently lose access to your funds.</string>
<string name="hw_remove_wallet_notification_title">Are you sure you want to forget this wallet?</string>
<string name="hw_remove_wallet_warning_access">I understand that if I haven\'t backed up my wallet before removing it, I may lose access to it.</string>
<string name="hw_remove_wallet_warning_device">I understand that removing my wallet does not delete it—but simply removes it from my device.</string>
<string name="hw_upgrade_backup_description">No seed phrase is needed anymore. Your Tangem card or ring becomes your secure backup.</string>
<string name="hw_remove_wallet_warning_device">I understand that removing my wallet does not delete it, only removes it from my device.</string>
<string name="hw_upgrade_backup_description">Seed phrase not required. Your Tangem card or ring becomes your secure backup.</string>
<string name="hw_upgrade_backup_title">Backup with Tangem</string>
<string name="hw_upgrade_error_card_already_has_wallet">Cant upgrade. A wallet already exists on this device.</string>
<string name="hw_upgrade_error_card_key_import">Pick another device. This one cant be used for the upgrade.</string>
@ -639,7 +639,7 @@
<string name="hw_upgrade_key_migration_title">Key Migration</string>
<string name="hw_upgrade_scan_device">Scan device</string>
<string name="hw_upgrade_start_action">Start upgrade</string>
<string name="hw_upgrade_start_description">Youre about to upgrade to our hardware wallet. This will keep your assets safe in cold storage.</string>
<string name="hw_upgrade_start_description">You\'re about to upgrade to our hardware wallet. It will keep your assets safe in cold storage.</string>
<string name="hw_upgrade_start_title">Tangem Wallet</string>
<string name="hw_upgrade_title">Upgrade to Hardware Wallet</string>
<string name="hw_upgrade_to_cold_banner_description">Keep your crypto safe with Tangem\'s top-tier hardware wallet.</string>
@ -726,6 +726,7 @@
<string name="markets_sort_by_top_gainers_title">Top Gainers</string>
<string name="markets_sort_by_top_losers_title">Top Losers</string>
<string name="markets_sort_by_trending_title">Trending</string>
<string name="markets_sort_by_yield_mode_title">Yield Mode</string>
<string name="markets_staking_banner_description_placeholder">Staking is the easiest way to receive rewards on your crypto. %s</string>
<string name="markets_staking_banner_title">Earn up to %s APY</string>
<string name="markets_token_added">Token Added</string>
@ -801,7 +802,7 @@
<string name="mobile_wallet_requires_min_os_warning_title">Mobile Wallet requires %1$s or later</string>
<string name="news_all_news">All news</string>
<plurals name="news_published_hours_ago">
<item quantity="one"></item>
<item quantity="one">%dh ago</item>
<item quantity="other">%dh ago</item>
</plurals>
<plurals name="news_published_minutes_ago">
@ -983,6 +984,7 @@
<string name="onramp_currency_other">Other currencies</string>
<string name="onramp_currency_popular">Popular Fiats</string>
<string name="onramp_currency_search">Search by currency</string>
<string name="onramp_error_transaction_already_processed">This transaction has already been processed. No further action is required.</string>
<string name="onramp_fetching_best_rates">Fetching best rates...</string>
<string name="onramp_instant_status">Instant</string>
<string name="onramp_legal">By using onramp functionality, you agree with providers %1$s and %2$s</string>

View file

@ -66,4 +66,6 @@ dependencies {
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -6,6 +6,7 @@ import com.tangem.core.ui.utils.DateTimeFormatters.dateMMMdd
import com.tangem.core.ui.utils.DateTimeFormatters.dateTimeFormatter
import com.tangem.core.ui.utils.DateTimeFormatters.dateYYYY
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatter
import org.joda.time.format.DateTimeFormatterBuilder
@ -98,6 +99,23 @@ object DateTimeFormatters {
getBestFormatterBySkeleton("dd.MM.yyyy HH:mm")
}
/**
* Local full date formatter (e.g., "dd MMMM, HH:mm")
*/
val localFullDate: DateTimeFormatter by lazy {
DateTimeFormatterBuilder()
.appendDayOfMonth(2)
.appendLiteral(' ')
.appendMonthOfYearText()
.appendLiteral(", ")
.appendHourOfDay(2)
.appendLiteral(':')
.appendMinuteOfHour(2)
.toFormatter()
.withLocale(Locale.getDefault())
.withZone(DateTimeZone.getDefault())
}
fun formatDate(date: DateTime, formatter: DateTimeFormatter = dateFormatter): String {
return formatter.print(date)
}

View file

@ -37,4 +37,51 @@ fun Long.toTimeFormat(formatter: DateTimeFormatter = DateTimeFormatters.timeForm
*/
fun Long.formatAsDateTime(formatter: DateTimeFormatter): String {
return DateTimeFormatters.formatDate(date = DateTime(this, DateTimeZone.getDefault()), formatter = formatter)
}
/**
* Parses an ISO 8601 date string and compares it to the current UTC time.
*
* @param now The current date to compare against.
* @return A [FormattedDate] subclass.
*/
@Suppress("MagicNumber")
fun getFormattedDate(createdAt: String, now: DateTime): FormattedDate {
val pastDateUtc = try {
DateTime.parse(createdAt)
} catch (_: Exception) {
return FormattedDate.FullDate(createdAt)
}
val pastDateLocal = pastDateUtc.withZone(DateTimeZone.getDefault())
val isToday = pastDateLocal.isToday()
val diffInMillis = now.millis - pastDateUtc.millis
val diffInMinutes = diffInMillis / (1000 * 60)
val diffInHours = diffInMillis / (1000 * 60 * 60)
return when {
diffInMinutes < 1 -> FormattedDate.MinutesAgo(1)
diffInMinutes < 60 -> FormattedDate.MinutesAgo(diffInMinutes.toInt())
diffInHours < 12 && isToday -> FormattedDate.HoursAgo(diffInHours.toInt())
isToday -> {
val timeString = DateTimeFormatters.timeFormatter.print(pastDateLocal)
FormattedDate.Today(timeString)
}
else -> {
val dateString = DateTimeFormatters.localFullDate.print(pastDateLocal)
FormattedDate.FullDate(dateString)
}
}
}
/**
* Representing different formatted date representations.
*/
sealed class FormattedDate {
data class MinutesAgo(val minutes: Int) : FormattedDate()
data class HoursAgo(val hours: Int) : FormattedDate()
data class Today(val time: String) : FormattedDate()
data class FullDate(val date: String) : FormattedDate()
}

View file

@ -0,0 +1,155 @@
package com.tangem.core.ui.utils
import com.google.common.truth.Truth
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DateUtilsTest {
private lateinit var defaultTimeZone: DateTimeZone
private val now = createDateTime(day = 14, hour = 12)
@BeforeEach
fun setUp() {
defaultTimeZone = DateTimeZone.getDefault()
DateTimeZone.setDefault(DateTimeZone.forID("Europe/Moscow"))
}
@AfterEach
fun tearDown() {
DateTimeZone.setDefault(defaultTimeZone)
}
@Test
fun `should return MinutesAgo when difference is less than 1 minute`() {
val pastDate = now.minusSeconds(30)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should return MinutesAgo when difference is 1 minute`() {
val pastDate = now.minusMinutes(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should return MinutesAgo when difference is 30 minutes`() {
val pastDate = now.minusMinutes(30)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(30)
}
@Test
fun `should return MinutesAgo when difference is 59 minutes`() {
val pastDate = now.minusMinutes(59).minusSeconds(59)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(59)
}
@Test
fun `should return HoursAgo when difference is exactly 1 hour`() {
val pastDate = now.minusHours(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.HoursAgo::class.java)
Truth.assertThat((result as FormattedDate.HoursAgo).hours).isEqualTo(1)
}
@Test
fun `should return HoursAgo when difference is 11 hours`() {
val pastDate = now.minusHours(11)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.HoursAgo::class.java)
Truth.assertThat((result as FormattedDate.HoursAgo).hours).isEqualTo(11)
}
@Test
fun `should return Today when difference is exactly 12 hours`() {
val pastDate = createDateTime(day = 14, hour = 0)
val createdAt = pastDate.toString()
val nowInTest = createDateTime(day = 14, hour = 12)
val result = getFormattedDate(createdAt, nowInTest)
Truth.assertThat(result).isInstanceOf(FormattedDate.Today::class.java)
Truth.assertThat((result as FormattedDate.Today).time).isEqualTo("03:00")
}
@Test
fun `should return FullDate when difference is 18 hours and past date is another day`() {
val pastDate = createDateTime(day = 13, hour = 18)
val createdAt = pastDate.toString()
val nowInTest = createDateTime(day = 14, hour = 12)
val result = getFormattedDate(createdAt, nowInTest)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate when difference is exactly 24 hours`() {
val pastDate = now.minusDays(1)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate when difference is 2 days`() {
val pastDate = now.minusDays(2)
val createdAt = pastDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
}
@Test
fun `should return FullDate with original string when format has wrong date separator`() {
val wrongFormat = "2025/10/14T12:00:00.000Z"
val result = getFormattedDate(wrongFormat, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.FullDate::class.java)
Truth.assertThat((result as FormattedDate.FullDate).date).isEqualTo(wrongFormat)
}
@Test
fun `should handle future dates correctly`() {
val futureDate = now.plusHours(1)
val createdAt = futureDate.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
@Test
fun `should handle edge case of exactly 0 milliseconds difference`() {
val createdAt = now.toString()
val result = getFormattedDate(createdAt, now)
Truth.assertThat(result).isInstanceOf(FormattedDate.MinutesAgo::class.java)
Truth.assertThat((result as FormattedDate.MinutesAgo).minutes).isEqualTo(1)
}
private fun createDateTime(day: Int, hour: Int): DateTime {
return DateTime(
/* year = */ 2025,
/* monthOfYear = */ 10,
/* dayOfMonth = */ day,
/* hourOfDay = */ hour,
/* minuteOfHour = */ 0,
/* secondOfMinute = */ 0,
/* millisOfSecond = */ 0,
/* zone = */ DateTimeZone.UTC,
)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.markets
import com.tangem.domain.markets.repositories.MarketsTokenRepository
class GetTopFiveMarketTokenUseCase(
private val marketsTokenRepository: MarketsTokenRepository,
) {
operator fun invoke(
batchingContext: TokenListBatchingContext,
order: TokenMarketListConfig.Order,
): TokenListBatchFlow {
return marketsTokenRepository.getTokenListFlow(
batchingContext = batchingContext,
firstBatchSize = DEFAULT_BATCH_SIZE,
nextBatchSize = NEXT_BATCH_SIZE,
)
}
companion object {
private const val DEFAULT_BATCH_SIZE = 5
private const val NEXT_BATCH_SIZE = 0
}
}

View file

@ -93,6 +93,7 @@ dependencies {
implementation(projects.common.ui)
implementation(projects.common.uiCharts)
implementation(projects.common.routing)
implementation(projects.common.uiMarkets)
/* Libs */
implementation(projects.libs.crypto)

View file

@ -10,7 +10,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface ComponentModule {
internal interface FeedComponentModule {
@Binds
@Singleton

View file

@ -0,0 +1,159 @@
package com.tangem.features.feed.model.converter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.state.MarketsListUM
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
internal class MarketsTokenItemConverter(
private val currentTrendInterval: MarketsListUM.TrendInterval,
private val appCurrency: AppCurrency,
) : Converter<TokenMarket, MarketsListItemUM> {
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
override fun convert(value: TokenMarket): MarketsListItemUM {
return MarketsListItemUM(
id = value.id,
name = value.name,
currencySymbol = value.symbol,
ratingPosition = value.marketRating?.toString(),
marketCap = value.getMarketCap(),
iconUrl = value.imageUrlLarge,
price = value.getCurrentPrice(),
trendPercentText = value.getTrendPercent(),
trendType = value.getTrendType(),
chartData = value.getChartData(),
isUnder100kMarketCap = value.isUnderMarketCapLimit,
stakingRate = value.stakingRate?.format { percent() }?.let {
resourceReference(R.string.markets_apy_placeholder, wrappedList(it))
},
updateTimestamp = value.updateTimestamp,
)
}
fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM {
require(prev.id == new.id) {
"Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]"
}
return prevUI.copy(
name = new.name,
currencySymbol = new.symbol,
ratingPosition = new.marketRating?.toString(),
marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() },
iconUrl = new.imageUrlLarge,
price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) {
new.getCurrentPrice(
prev = prev,
)
},
trendPercentText = ifChanged(
prev.tokenQuotesShort,
new.tokenQuotesShort,
prevUI.trendPercentText,
) { new.getTrendPercent() },
trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() },
chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() },
)
}
private inline fun <T, R> ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R {
return if (force || prev != new) change(new) else prevR
}
private fun TokenMarket.getMarketCap(): String? {
val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null
return value.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).compact(
threeDigitsMethod = true,
)
}
}
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
val prevPrice = prev?.tokenQuotesShort?.currentPrice
val priceText = tokenQuotesShort.currentPrice.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).price()
}
val changeType = if (prevPrice != null) {
if (tokenQuotesShort.currentPrice > prevPrice) {
PriceChangeType.UP
} else {
PriceChangeType.DOWN
}
} else {
null
}
return MarketsListItemUM.Price(
text = priceText,
changeType = changeType,
)
}
private fun TokenMarket.getChartData(): MarketChartRawData? {
val chart = when (currentTrendInterval) {
MarketsListUM.TrendInterval.H24 -> tokenCharts.h24
MarketsListUM.TrendInterval.D7 -> tokenCharts.week
MarketsListUM.TrendInterval.M1 -> tokenCharts.month
}
return chart?.let { ct ->
priceAndTimePointValuesConverter.convert(
MarketChartData.Data(
y = ct.priceY.toImmutableList(),
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted(),
)
}
}
@Suppress("MagicNumber")
private fun TokenMarket.getTrendType(): PriceChangeType {
val percent = when (currentTrendInterval) {
MarketsListUM.TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent
MarketsListUM.TrendInterval.D7 -> tokenQuotesShort.weekChangePercent
MarketsListUM.TrendInterval.M1 -> tokenQuotesShort.monthChangePercent
}
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
private fun TokenMarket.getTrendPercent(): String {
val percent = when (currentTrendInterval) {
MarketsListUM.TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent
MarketsListUM.TrendInterval.D7 -> tokenQuotesShort.weekChangePercent
MarketsListUM.TrendInterval.M1 -> tokenQuotesShort.monthChangePercent
}
return percent.format { percent() }
}
}

View file

@ -1,11 +1,16 @@
package com.tangem.features.feed.model.feed
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase
import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase
import com.tangem.features.feed.impl.R
@ -13,10 +18,12 @@ import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentHashMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
@ -28,11 +35,30 @@ internal class FeedComponentModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase,
private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase,
getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
) : Model() {
private val _state = MutableStateFlow(initialState())
val state = _state.asStateFlow()
private var quotesUpdateJob: Job? = null
private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
private val marketsBatchFlowManager = FeedMarketsBatchFlowManager(
getTopFiveMarketTokenUseCase = getTopFiveMarketTokenUseCase,
currentAppCurrency = Provider { currentAppCurrency.value },
modelScope = modelScope,
dispatchers = dispatchers,
)
private val searchBarStateFactory by lazy(LazyThreadSafetyMode.NONE) {
SearchBarStateFactory(
currentStateProvider = Provider { _state.value },
@ -55,8 +81,45 @@ internal class FeedComponentModel @Inject constructor(
_state.update { feedListUM ->
feedListUM.copy(
searchBar = _state.value.searchBar.copy(onQueryChange = searchBarStateFactory::onSearchQueryChange),
feedListCallbacks = feedListUM.feedListCallbacks.copy(
onSortTypeClick = ::onSortTypeClick,
),
)
}
modelScope.launch(dispatchers.default) {
combine(
marketsBatchFlowManager.itemsByOrder,
marketsBatchFlowManager.loadingStatesByOrder,
marketsBatchFlowManager.errorStatesByOrder,
) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder ->
updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder)
val currentSortType = _state.value.marketChartConfig.currentSortByType
val items = itemsByOrder[currentSortType]
val isLoading = loadingStatesByOrder[currentSortType] == true
if (items != null && items.isNotEmpty() && !isLoading) {
val order = currentSortType.toOrder()
marketsBatchFlowManager.loadCharts(order)
}
}.collect()
}
modelScope.launch(dispatchers.default) {
currentAppCurrency.drop(1).collect {
marketsBatchFlowManager.reloadAll()
}
}
modelScope.launch(dispatchers.default) {
TokenMarketListConfig.Order.entries.forEach { order ->
marketsBatchFlowManager.getOnLastBatchLoadedSuccessFlow(order)?.collect { batchKey ->
marketsBatchFlowManager.loadCharts(order)
if (batchKey == 0) {
startQuotesUpdateTimer()
}
}
}
}
}
private suspend fun subscribeOnTrendingNews() {
@ -88,10 +151,10 @@ internal class FeedComponentModel @Inject constructor(
marketChartConfig = MarketChartConfig(
marketCharts = buildMap {
SortByTypeUM.entries.forEach {
put(it, MarketChartUM.Loading)
} // TODO in [REDACTED_TASK_KEY] add correct sorting
put(it, MarketChartUM.LoadingError(onRetryClicked = marketsBatchFlowManager::reloadAll))
}
}.toPersistentHashMap(),
currentSortByType = SortByTypeUM.TopGainers,
currentSortByType = SortByTypeUM.Trending,
),
)
}
@ -100,4 +163,116 @@ internal class FeedComponentModel @Inject constructor(
val localDate = DateTime(DateTime.now(), DateTimeZone.getDefault())
return DateTimeFormatters.formatDate(formatter = DateTimeFormatters.dateDMMM, date = localDate)
}
private fun updateMarketCharts(
itemsByOrder: Map<SortByTypeUM, ImmutableList<com.tangem.common.ui.markets.models.MarketsListItemUM>>,
loadingStatesByOrder: Map<SortByTypeUM, Boolean>,
errorStatesByOrder: Map<SortByTypeUM, Boolean>,
) {
_state.update { currentState ->
val newMarketCharts = buildMap {
SortByTypeUM.entries.forEach { sortByType ->
val items = itemsByOrder[sortByType] ?: persistentListOf()
val isLoading = loadingStatesByOrder[sortByType] == true
val hasError = errorStatesByOrder[sortByType] == true
when {
hasError -> {
put(
sortByType,
MarketChartUM.LoadingError(
onRetryClicked = {
marketsBatchFlowManager.reloadAll()
},
),
)
}
isLoading -> {
put(sortByType, MarketChartUM.Loading)
}
items.isEmpty() -> {
put(
sortByType,
MarketChartUM.LoadingError(
onRetryClicked = {
marketsBatchFlowManager.reloadAll()
},
),
)
}
else -> {
put(
sortByType,
MarketChartUM.Content(
items = items,
sortChartConfig = SortChartConfigUM(
sortByType = sortByType,
isSelected = sortByType == currentState.marketChartConfig.currentSortByType,
),
),
)
}
}
}
}.toPersistentHashMap()
currentState.copy(
marketChartConfig = currentState.marketChartConfig.copy(
marketCharts = newMarketCharts,
),
)
}
}
private fun onSortTypeClick(sortByType: SortByTypeUM) {
_state.update { currentState ->
val updatedCharts = currentState.marketChartConfig.marketCharts.mapValues { (chartSortType, chart) ->
when (chart) {
is MarketChartUM.Content -> {
chart.copy(
sortChartConfig = chart.sortChartConfig.copy(
isSelected = chartSortType == sortByType,
),
)
}
else -> chart
}
}
currentState.copy(
marketChartConfig = currentState.marketChartConfig.copy(
currentSortByType = sortByType,
marketCharts = updatedCharts.toPersistentHashMap(),
),
)
}
modelScope.launch(dispatchers.default) {
marketsBatchFlowManager.loadCharts(sortByType.toOrder())
}
}
private fun startQuotesUpdateTimer() {
quotesUpdateJob?.cancel()
quotesUpdateJob = modelScope.launch {
while (true) {
delay(DELAY_TO_FETCH_QUOTES)
marketsBatchFlowManager.updateQuotes()
}
}
}
private fun SortByTypeUM.toOrder(): TokenMarketListConfig.Order {
return when (this) {
SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating
SortByTypeUM.Trending -> TokenMarketListConfig.Order.Trending
SortByTypeUM.ExperiencedBuyers -> TokenMarketListConfig.Order.Buyers
SortByTypeUM.TopGainers -> TokenMarketListConfig.Order.TopGainers
SortByTypeUM.TopLosers -> TokenMarketListConfig.Order.TopLosers
SortByTypeUM.Staking -> TokenMarketListConfig.Order.Staking
}
}
companion object {
private const val DELAY_TO_FETCH_QUOTES = 60_000L
}
}

View file

@ -14,7 +14,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -29,6 +31,10 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.markets.MarketChartLoadingError
import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.news.ArticleCard
import com.tangem.common.ui.news.ArticleConfigUM
import com.tangem.core.ui.R
@ -49,9 +55,6 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.components.MarketsListItem
import com.tangem.features.feed.ui.market.components.MarketsListItemPlaceholder
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
@Composable
@ -101,10 +104,12 @@ internal fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
SpacerH(32.dp)
MarketBlock(
marketChartConfig = state.marketChartConfig,
feedListCallbacks = state.feedListCallbacks,
)
state.marketChartConfig.marketCharts[SortByTypeUM.Rating]?.let { marketChartUM ->
MarketBlock(
marketChart = marketChartUM,
feedListCallbacks = state.feedListCallbacks,
)
}
NewsBlock(
news = state.news,
@ -120,44 +125,43 @@ internal fun FeedListContent(state: FeedListUM, modifier: Modifier = Modifier) {
}
@Composable
private fun MarketBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
if (marketChartConfig.marketCharts.isNotEmpty()) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
)
SpacerH(12.dp)
marketChartConfig.marketCharts[SortByTypeUM.Rating]?.let { chart ->
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = chart,
private fun MarketBlock(marketChart: MarketChartUM, feedListCallbacks: FeedListCallbacks) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
}
SpacerH(32.dp)
}
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(SortByTypeUM.Rating) },
)
SpacerH(12.dp)
Charts(
onItemClick = feedListCallbacks.onMarketItemClick,
modifier = Modifier.padding(horizontal = 16.dp),
marketChart = marketChart,
)
SpacerH(32.dp)
}
@Composable
private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallbacks: FeedListCallbacks) {
val onSeeAllClick by rememberUpdatedState {
feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType)
}
if (marketChartConfig.marketCharts.isNotEmpty()) {
Header(
title = {
Text(
text = stringResourceSafe(R.string.markets_common_title),
text = stringResourceSafe(R.string.markets_pulse_common_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
},
onSeeAllClick = { feedListCallbacks.onMarketOpenClick(marketChartConfig.currentSortByType) },
onSeeAllClick = { onSeeAllClick() },
)
LazyRow(
@ -330,11 +334,7 @@ private fun Charts(
modifier = modifier,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Column(modifier = Modifier.fillMaxWidth()) {
when (marketChart) {
MarketChartUM.Loading -> {
repeat(DEFAULT_CHART_SIZE_IN_MARKET) {
@ -342,7 +342,10 @@ private fun Charts(
}
}
is MarketChartUM.LoadingError -> {
// TODO will be created in [REDACTED_TASK_KEY]
MarketChartLoadingError(
modifier = Modifier.fillMaxWidth(),
onRetryClick = marketChart.onRetryClicked,
)
}
is MarketChartUM.Content -> {
marketChart.items.fastForEach { chart ->

View file

@ -8,13 +8,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.common.ui.news.DefaultLoadingArticle
import com.tangem.common.ui.news.TrendingLoadingArticle
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.res.TangemThemePreview
import com.tangem.features.feed.ui.market.components.MarketsListItemPlaceholder
@Composable
internal fun MarketLoadingBlock() {

View file

@ -1,17 +1,16 @@
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.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM
import com.tangem.core.ui.components.label.entity.LabelUM
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.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.feed.ui.feed.state.*
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import kotlinx.collections.immutable.*
@ -94,7 +93,6 @@ internal object FeedListPreviewDataProvider {
): MarketChartUM.Content {
return MarketChartUM.Content(
items = items,
triggerScrollReset = consumedEvent(),
sortChartConfig = SortChartConfigUM(
sortByType = sortByType,
isSelected = isSelected,
@ -107,7 +105,7 @@ internal object FeedListPreviewDataProvider {
id = 1,
title = "Bitcoin ETF reaches new highs, institutions pile in",
score = 0.82f,
createdAt = "2h ago",
createdAt = TextReference.Str("Yesterday"),
isTrending = true,
tags = createArticleTags(),
isViewed = false,
@ -116,7 +114,7 @@ internal object FeedListPreviewDataProvider {
id = 2,
title = "Layer 2 networks battle for dominance amid fee wars",
score = 0.71f,
createdAt = "4h ago",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = true,
@ -125,7 +123,7 @@ internal object FeedListPreviewDataProvider {
id = 3,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = false,
@ -134,7 +132,7 @@ internal object FeedListPreviewDataProvider {
id = 4,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = false,
@ -143,7 +141,7 @@ internal object FeedListPreviewDataProvider {
id = 5,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = false,
@ -152,7 +150,7 @@ internal object FeedListPreviewDataProvider {
id = 6,
title = "Stablecoins expand on-ramps across LATAM",
score = 0.65f,
createdAt = "Yesterday",
createdAt = TextReference.Str("Yesterday"),
isTrending = false,
tags = createArticleTags(),
isViewed = false,

View file

@ -1,10 +1,9 @@
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.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.event.StateEvent
import com.tangem.features.feed.ui.market.state.MarketsListItemUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@ -46,7 +45,6 @@ internal sealed interface MarketChartUM {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val triggerScrollReset: StateEvent<Unit>,
val sortChartConfig: SortChartConfigUM,
) : MarketChartUM

View file

@ -0,0 +1,378 @@
package com.tangem.features.feed.ui.feed.state
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.features.feed.model.converter.MarketsTokenItemConverter
import com.tangem.features.feed.ui.market.state.MarketsListUM
import com.tangem.features.feed.ui.market.state.SortByTypeUM
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class FeedMarketsBatchFlowManager(
private val getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase,
private val currentAppCurrency: Provider<AppCurrency>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val managersByOrder = TokenMarketListConfig.Order.entries.associateWith { order ->
createManagerForOrder(order)
}
val itemsByOrder: StateFlow<Map<SortByTypeUM, ImmutableList<MarketsListItemUM>>> =
combine(
TokenMarketListConfig.Order.entries.mapNotNull { order ->
managersByOrder[order]?.uiItems?.map { items -> order to items }
},
) { itemsList ->
itemsList.associate { (order, items) ->
val sortByType = order.toSortByTypeUM()
sortByType to items
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = emptyMap(),
)
val loadingStatesByOrder: StateFlow<Map<SortByTypeUM, Boolean>> =
combine(
TokenMarketListConfig.Order.entries.mapNotNull { order ->
managersByOrder[order]?.isLoading?.map { isLoading -> order to isLoading }
},
) { loadingStatesList ->
loadingStatesList.associate { (order, isLoading) ->
val sortByType = order.toSortByTypeUM()
sortByType to isLoading
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = emptyMap(),
)
val errorStatesByOrder: StateFlow<Map<SortByTypeUM, Boolean>> =
combine(
TokenMarketListConfig.Order.entries.mapNotNull { order ->
managersByOrder[order]?.hasError?.map { hasError -> order to hasError }
},
) { errorStatesList ->
errorStatesList.associate { (order, hasError) ->
val sortByType = order.toSortByTypeUM()
sortByType to hasError
}
}.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = emptyMap(),
)
init {
managersByOrder.values.forEach { manager ->
manager.reload(currentAppCurrency().code)
}
}
private fun createManagerForOrder(order: TokenMarketListConfig.Order): SingleOrderManager {
val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
val batchFlow = getTopFiveMarketTokenUseCase(
batchingContext = TokenListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
order = order,
)
return SingleOrderManager(
order = order,
actionsFlow = actionsFlow,
batchFlow = batchFlow,
currentAppCurrency = currentAppCurrency,
modelScope = modelScope,
dispatchers = dispatchers,
)
}
fun reloadAll() {
managersByOrder.values.forEach { manager ->
manager.reload(currentAppCurrency().code)
}
}
fun updateQuotes() {
managersByOrder.values.forEach { manager ->
manager.updateQuotes(currentAppCurrency().code)
}
}
fun loadCharts(order: TokenMarketListConfig.Order) {
managersByOrder[order]?.loadCharts()
}
fun getOnLastBatchLoadedSuccessFlow(order: TokenMarketListConfig.Order): Flow<Int>? {
return managersByOrder[order]?.onLastBatchLoadedSuccess
}
private class SingleOrderManager(
val order: TokenMarketListConfig.Order,
private val actionsFlow: MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>,
private val batchFlow: TokenListBatchFlow,
private val currentAppCurrency: Provider<AppCurrency>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val updateStateJob = JobHolder()
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = resultBatches.map { it.uiBatches }
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>> =
uiBatches
.map { batches ->
batches.asSequence()
.map { it.data }
.flatten()
.toImmutableList()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = persistentListOf(),
)
val isLoading = batchFlow.state
.map { state ->
when (state.status) {
is PaginationStatus.InitialLoading -> true
is PaginationStatus.NextBatchLoading -> true
else -> false
}
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = false,
)
val hasError = batchFlow.state
.map { state ->
when (val status = state.status) {
is PaginationStatus.InitialLoadingError -> true
is PaginationStatus.Paginating -> status.lastResult is BatchFetchResult.Error
else -> false
}
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = false,
)
val onLastBatchLoadedSuccess = batchFlow.state
.distinctUntilChanged { old, new -> old.status == new.status && old.data.size == new.data.size }
.mapNotNull { batchListState ->
when (val status = batchListState.status) {
is PaginationStatus.Paginating -> {
if (status.lastResult is BatchFetchResult.Success) {
batchListState.data.lastOrNull()?.key
} else {
null
}
}
is PaginationStatus.EndOfPagination -> {
batchListState.data.lastOrNull()?.key
}
else -> null
}
}
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size &&
a.map { it.key } == b.map { it.key } &&
a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEach {
coroutineScope {
launch {
updateState(it)
}.saveIn(updateStateJob)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
withContext(dispatchers.default) {
resultBatches.update { resultBatches ->
val items = resultBatches.uiBatches
val previousList = resultBatches.processedItems
val converter = MarketsTokenItemConverter(
currentTrendInterval = MarketsListUM.TrendInterval.H24,
appCurrency = currentAppCurrency(),
)
if (newList.isEmpty()) {
return@update ResultBatches(processedItems = emptyList())
}
val isInitialLoading =
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
val outItems = if (isInitialLoading) {
newList.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
// As nextBatchSize = 0, we only have one batch, but keep the logic for safety
if (previousList.size != newList.size) {
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = newList.filter { keysToAdd.contains(it.key) }
items + newBatches.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (prevBatch == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data.getOrNull(index)
val newItem = newBatch.data.getOrNull(index)
if (prevItem != null && newItem != null) {
converter.update(prevItem, marketsListItemUM, newItem)
} else {
newItem?.let { converter.convert(it) } ?: marketsListItemUM
}
},
)
}
}
}
currentCoroutineContext().ensureActive()
ResultBatches(
uiBatches = outItems,
processedItems = newList,
)
}
}
fun reload(fiatPriceCurrency: String) {
modelScope.launch(dispatchers.default) {
resultBatches.value = ResultBatches()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
fiatPriceCurrency = fiatPriceCurrency,
searchText = null,
priceChangeInterval = TokenMarketListConfig.Interval.H24,
order = order,
),
),
)
}
}
fun updateQuotes(fiatPriceCurrency: String) {
modelScope.launch(dispatchers.default) {
actionsFlow.emit(
BatchAction.CancelUpdates {
it.updateRequest is TokenMarketUpdateRequest.UpdateQuotes
},
)
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchFlow
.state
.value
.data
.map { it.key }
.toSet(),
updateRequest = TokenMarketUpdateRequest.UpdateQuotes(
currencyId = fiatPriceCurrency,
),
async = true,
operationId = "update quotes",
),
)
}
}
fun loadCharts() {
modelScope.launch(dispatchers.default) {
val currentData = batchFlow.state.value.data
val alreadyLoadedChartsBatchKeys = currentData
.filter { batch ->
val first = batch.data.firstOrNull() ?: return@filter false
first.tokenCharts.h24 != null
}
.map { it.key }
.toSet()
val batchesKeysToLoad = currentData.map { it.key }.toSet().minus(alreadyLoadedChartsBatchKeys)
if (batchesKeysToLoad.isNotEmpty()) {
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchesKeysToLoad,
updateRequest = TokenMarketUpdateRequest.UpdateChart(
interval = TokenMarketListConfig.Interval.H24,
currency = currentAppCurrency().code,
),
async = true,
operationId = batchesKeysToLoad.toString() + "h24",
),
)
}
}
}
private data class ResultBatches(
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
}
private fun TokenMarketListConfig.Order.toSortByTypeUM(): SortByTypeUM {
return when (this) {
TokenMarketListConfig.Order.ByRating -> SortByTypeUM.Rating
TokenMarketListConfig.Order.Trending -> SortByTypeUM.Trending
TokenMarketListConfig.Order.Buyers -> SortByTypeUM.ExperiencedBuyers
TokenMarketListConfig.Order.TopGainers -> SortByTypeUM.TopGainers
TokenMarketListConfig.Order.TopLosers -> SortByTypeUM.TopLosers
TokenMarketListConfig.Order.Staking -> SortByTypeUM.Staking
}
}
}

View file

@ -4,12 +4,19 @@ import com.tangem.common.ui.news.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
import com.tangem.core.ui.extensions.WrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.FormattedDate
import com.tangem.core.ui.utils.getFormattedDate
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.news.ShortArticle
import com.tangem.features.feed.impl.R
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import org.joda.time.DateTime
internal class TrendingNewsStateFactory(
private val currentStateProvider: Provider<FeedListUM>,
@ -47,7 +54,7 @@ internal class TrendingNewsStateFactory(
)
},
).toPersistentSet(),
createdAt = "1 min ago", // TODO in [REDACTED_TASK_KEY]
createdAt = mapFormattedDate(article.createdAt),
isViewed = article.viewed,
)
},
@ -72,7 +79,7 @@ internal class TrendingNewsStateFactory(
)
},
).toPersistentSet(),
createdAt = "1 min ago", // TODO in [REDACTED_TASK_KEY]
createdAt = mapFormattedDate(article.createdAt),
isViewed = article.viewed,
)
}.toPersistentList(),
@ -80,4 +87,34 @@ internal class TrendingNewsStateFactory(
),
)
}
private fun mapFormattedDate(createdAt: String): TextReference {
val formattedDate = getFormattedDate(
createdAt = createdAt,
now = DateTime.now(),
)
return when (formattedDate) {
is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date)
is FormattedDate.HoursAgo -> TextReference.PluralRes(
id = R.plurals.news_published_hours_ago,
count = formattedDate.hours,
formatArgs = wrappedList(formattedDate.hours),
)
is FormattedDate.MinutesAgo -> TextReference.PluralRes(
id = R.plurals.news_published_minutes_ago,
count = formattedDate.minutes,
formatArgs = wrappedList(formattedDate.minutes),
)
is FormattedDate.Today -> TextReference.Combined(
refs = WrappedList(
data = listOf(
TextReference.Res(R.string.common_today),
TextReference.Str(StringsSigns.COMA_SIGN),
TextReference.Str(StringsSigns.WHITE_SPACE),
TextReference.Str(formattedDate.time),
),
),
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.feed.ui.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.fields.entity.SearchBarUM

View file

@ -175,6 +175,7 @@ include(":common:routing")
include(":common:test")
include(":common:ui")
include(":common:ui-charts")
include(":common:ui-markets")
// endregion
// region Libs modules