diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index ef41aa0007..d6bfbdb63a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -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 { diff --git a/common/ui-markets/.gitignore b/common/ui-markets/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/common/ui-markets/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/ui-markets/build.gradle.kts b/common/ui-markets/build.gradle.kts new file mode 100644 index 0000000000..b83e4298e2 --- /dev/null +++ b/common/ui-markets/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketChartLoadingError.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketChartLoadingError.kt new file mode 100644 index 0000000000..2cbcf9ad7c --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketChartLoadingError.kt @@ -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, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItem.kt similarity index 97% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItem.kt index 9f011aa99a..f2931ff828 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketListItem.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItem.kt @@ -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() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemPlaceholder.kt similarity index 98% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemPlaceholder.kt index ab52d344f0..3e11c3c039 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/components/MarketsListItemPlaceholder.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketsListItemPlaceholder.kt @@ -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 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/models/MarketsListItemUM.kt similarity index 96% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/models/MarketsListItemUM.kt index 5e518373dc..9d48452aef 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListItemUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/models/MarketsListItemUM.kt @@ -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 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/preview/MarketChartListItemPreviewDataProvider.kt similarity index 95% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/preview/MarketChartListItemPreviewDataProvider.kt index 98dd938ce2..7cac4e8314 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/preview/MarketChartListItemPreviewDataProvider.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/preview/MarketChartListItemPreviewDataProvider.kt @@ -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( +class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider( collection = listOf( MarketsListItemUM( id = CryptoCurrency.RawID("1"), diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt index 70d50e3b81..91e26bb895 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleCard.kt @@ -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, diff --git a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt index 46e616eaab..46714a0ba8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/news/ArticleConfigUM.kt @@ -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, val isViewed: Boolean, diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index a009fa485f..0dc6af1380 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -849,6 +849,9 @@ Das Zielkonto verfügt nicht über eine Vertrauensstellung für das gesendete Asset. Sicher Dir 10 $ in BTC mit jeder Wallet greif schnell zu! Black Friday: bis zu 30% Nachlass + Los geht\'s + Nur für kurze Zeit! + 1+1: Beim Kauf einer Wallet erhältst Du 50% Rabatt Jetzt beitreten Teile Deinen Code – verdiene 5 USDT pro Verkauf. Deine Freunde erhalten 10 % Rabatt. Erhalte BELOHNUNGEN für jeden Freund! diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 94e4382158..82aca73289 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -848,8 +848,8 @@ ウォレットごとに$10相当のBTCをプレゼント\nお早めに! ブラックフライデー:最大30% オフ 今すぐチェック - 理想のTangemセットを揃えよう。期間限定。 - 1+1:1つ購入で、2つ目が50%オフ + 期間限定! + 1+1:ウォレット1つ購入で、2つ目が50%オフ 今すぐ参加 コードを共有すると、販売ごとに5 USDTを獲得できます。お友達は10%割引になります。 友達への紹介で報酬を獲得しよう! @@ -1391,7 +1391,7 @@ 取引を表示 サービス手数料 手数料 - カードを紛失したり盗まれたりしても、資金を安全に保てます。一時停止はいつでも解除できます。 + 資金を安全に保管します。凍結はいつでも解除できます。 カードを一時停止しますか? カードを凍結できませんでした。しばらくしてからもう一度お試しください。 一時停止 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index b5587d3945..0ad7e694bf 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -16,8 +16,10 @@ Код доступа Превышен лимит %1$s аккаунтов. Архивируйте, чтобы создать новый. Невозможно добавить новый аккаунт. + Аккаунт заархивирован Архивированные аккаунты Восстановить + Вы собираетесь восстановить \"%1$s\". Восстановить аккаунт Лимит в 20 активных аккаунтов достигнут. Пожалуйста, зархивируйте один аккаунт, чтобы продолжить. Невозможно восстановить аккаунт @@ -26,12 +28,14 @@ Этот аккаунт участвует в реферальной программе Этот аккаунт не может быть архивирован. Мы не смогли создать аккаунт. Пожалуйста, попробуйте позже. + Аккаунт создан Архивный аккаунт Архив Вы архивируете свой аккаунт, но в любое время можете вернуть его обратно Архивация... Аккаунт Невозможно отредактировать аккаунт + Аккаунт сохранен Аккаунт для наград Добавить аккаунт Сохранить @@ -46,6 +50,8 @@ Основной аккаунт Лимит %1$s активных аккаунтов превышен. Архивируйте один, чтобы продолжить. Невозможно восстановить аккаунт + Аккаунт восстановлен + Нажмите и удерживайте аккаунт, чтобы изменить порядок аккаунтов. Продолжить Отменить Вы уверены, что хотите создание нового аккаунта? @@ -87,14 +93,17 @@ Токены в сети %1$s не поддерживаются этой картой или кольцом из-за ограничений прошивки. У вас возникли трудности со сканированием карты или кольца? Эта карта не предназначена для работы с этим приложением + Сначала установите код доступа, чтобы включить биометрию. Используйте %1$s, чтобы быстро и безопасно разблокировать кошелёк и выполнять чувствительные действия, например, подписывать транзакции. Для аппаратных кошельков всё ещё требуется карта или кольцо для подписи. Комиссия по-умолчанию Подключите функцию комиссии по умолчанию и при формировании транзакции на отправку средств комиссия будет выставлена автоматически, а экран комиссии пропущен. Вы всегда сможете на него вернуться. Перейдите в настройки, чтобы включить биометрическую аутентификацию в приложении Tangem Включите биометрическую аутентификацию Отключение %1$s потребует ввода вашего кода доступа для разблокировки приложения и работы с кошельком. + Позже будет необходимо ввести код доступа к вашему кошельку, чтобы мы могли безопасно сохранить его для будущего использования. Все сохраненные коды доступа будут удалены. Вам потребуется вводить код доступа при работе с кошельком. При отключении функции сохранения кошелька все ранее сохраненные кошельки будут удалены из приложения. + Это удалит все сохранённые коды доступа к кошелькам. Любое дальнейшее взаимодействие с кошельком будет требовать ввода кода доступа. Требуется код доступа Эта опция отключает использование биометрии для выполнения чувствительных действий. Каждый раз, например при подписании транзакции, вам потребуется вводить код доступа. Сохранение кода доступа @@ -144,6 +153,7 @@ Вход по биометрии временно заблокирован. Попробуйте снова через 30 секунд или разблокируйте кошелёк с помощью прикладывания устройства или кода доступа. Слишком много попыток Вы отключили биометрическую аутентификацию на вашем телефоне и не сможете сохранять кошельки в приложении. Для сохранения кошельков, пожалуйста, включите функцию биометрической аутентификации в настройках телефона. + Биометрические данные на вашем устройстве были обновлены. Пожалуйста, выберите свой кошелёк и введите его код доступа, чтобы повторно включить вход по биометрии. Внимание При обработке промокода произошла ошибка. Пожалуйста, попробуйте позже. Ошибка активации @@ -299,6 +309,7 @@ Позже Узнать больше Осталось %1$s + Легаси Bitcoin Заблокирован Заблокированные кошельки Основная сеть @@ -311,6 +322,8 @@ %d сетей %d сети + Новый адрес + Новости Далее NFT Нет @@ -363,6 +376,7 @@ Поддержка Поддерживаемые сети Обменять + Tangem условия участия Условиями использования На @@ -391,6 +405,7 @@ Адрес контракта скопирован! Доступные сети Деривация вашего токена совпадает с деривацией %1$s. Токен будет добавлен к этой учётной записи. + Деривация принадлежит другому аккаунту Токен добавлен в аккаунт %1$s Адрес контракта Адрес контракта некорректен @@ -527,6 +542,8 @@ ID: %s ID транзакции скопирован Обменяйте любой актив в вашем портфеле на этот токен + Рынок и Новости + В тренде Информация ниже не является обязательной. Вы можете стереть её, если хотите. Расскажите, каких функций вам не хватает, и мы постараемся вам помочь. Скажите, пожалуйста, какая у вас карта или кольцо? @@ -687,6 +704,7 @@ APY %s Мой портфель Рынок + Зарабатывай с Tangem Чтобы создать адреса для выбранных сетей, отсканируйте вашу карту или кольцо Tangem кошелька Потяните вверх или коснитесь поисковой строки, чтобы добавить токен Данные раздела получены из следующих сетей: %s @@ -791,6 +809,8 @@ Активировать режим доходности Обновитесь до версии %1$s, чтобы создать мобильный кошелёк Мобильный кошелек требует %1$s или новее. + Все новости + Будьте в курсе Функция NFC недоступна на вашем устройстве О NFT NFT @@ -1198,6 +1218,7 @@ %1$s — это монета в сети Tron. Чтобы рассчитать комиссию и совершить транзакцию, вам необходимо внести немного Tron (TRX) на свой адрес. Сумма превышает остаток Для завершения транзакции на указанный адрес необходим тег назначения (мемо). + Требуется Destination Tag Недопустимая сумма Комиссия превышает остаток Отправляемая сумма превышает остаток @@ -1225,6 +1246,8 @@ Сетевая комиссия — это небольшая плата за обработку и подтверждение вашей транзакции в блокчейне. Чтобы начать стейкинг, ваш TON-аккаунт должен быть активирован транзакцией самому себе на 1 TON. Средства остаются в вашем кошельке — этот шаг лишь активирует ваш аккаунт для участия в стейкинге. Активация аккаунта + Сетевая комиссия изменилась. Пожалуйста, проверьте новую сумму перед продолжением. + Комиссия сети обновлена Сумма для стейкинга должна быть не менее %s Согласно правилам сети, сумма стейкинга будет округлена до %1$sTRX. Сумма для вывода из стейкинга будет округлена до %1$s TRX ввиду особенностей сети. @@ -1263,6 +1286,7 @@ Комиссия сети за одобрение токена будет взиматься за подтверждение того, что именно вы разрешаете использовать ваш токен для стейкинга. Пользуясь стейкинг сервисом, вы соглашаетесь с %1$s и %2$s Заблокировано + Максимальная сумму: %s Переместить Нативный стейкинг В данный момент нет доступных активных валидаторов для стейкинга. Пожалуйста, попробуйте позже. @@ -1426,7 +1450,7 @@ Разморозить карту? Не удалось разморозить карту, попробуйте еще раз Карта разморожена - Вывод + Вывести Пополнить Способы пополнения Номер @@ -1462,7 +1486,7 @@ Пополните карту любым активом через обмен Реквизиты Разморозить карту - Вывод + Вывести Вывод сейчас недоступен Вы не можете начать обмен или новый вывод, пока не завершится текущий. Вывод выполняется @@ -1598,6 +1622,7 @@ Код доступа Активация аккаунта ПИН не принят. Попробуйте ещё раз или введите другой код. + Разблокировать Выберите способ добавления кошелька Отсканируйте вашу карту или кольцо Tangem, чтобы восстановить её или импортировать из другого кошелька. Создать аппаратный кошелёк @@ -1934,6 +1959,7 @@ Политика комиссий за пополнение Tangem взимает комиссию за обслуживание в размере 15% от полученного дохода. Ваши средства будут автоматически переведены в Aave, как только комиссия сети снизится или баланс достигнет минимально необходимой суммы. + Высокая сетевая комиссия Историческая доходность Получай до %1$s APY на свой баланс Разрешение для вашего токена в режиме доходности было отозвано. Откройте токен, чтобы выдать разрешение снова. diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 8c52e119ff..a82169d021 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -584,30 +584,30 @@ Finalize wallet setup Complete setup by securing the app with an access code. If you do, you\'ll need to start over. - Are you sure you want to quit the activation process? - Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault. + Are you sure you want to quit the setup process? + Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault. If you do, you\'ll need to start over. Recover existing wallet via Google Drive backup Google Drive backup - Create a new secure wallet and transfer your funds for extra protection. + Create a secure wallet and transfer your funds for extra protection. Create new wallet Level up your security with the superior Tangem hardware wallet. Hardware Wallet - Move your current wallet into Tangem Wallet. + Move your current wallet into Tangem. Upgrade current wallet Go to backup Please back up your wallet before creating an access code. - Finish Backup First + Finish backup first Finalize backup first Incomplete Other methods - Manually save your recovery phrase in a secure place and keep it private to protect your funds. + Save your recovery phrase in a secure place and keep it private to protect your funds. Recovery phrase To secure your wallet with an Access Code, complete the backup first. - To upgrade your wallet to hardware, back it up first. + To upgrade to a hardware wallet, complete the backup process. Your private keys are securely encrypted and stored on your phone - Keys are stored in the app - Create or restore your wallet using a recovery phrase — your built-in backup. + Private keys stay on your device + Create or restore your wallet with a recovery phrase. Seed phrase backup Create Mobile Wallet Import existing wallet @@ -620,13 +620,13 @@ Go to backup View backup Forget wallet - Forget Anyway - A backup for this wallet exists. Review it before forgetting to make sure you can recover later. - If you forget this wallet without a backup, you’ll permanently lose access to your funds. - Forget this wallet? + Forget anyway + This wallet has a backup. Ensure you can recover it before forgetting the wallet. + If you forget this wallet without a backup, you\'ll permanently lose access to your funds. + Are you sure you want to forget this wallet? I understand that if I haven\'t backed up my wallet before removing it, I may lose access to it. - I understand that removing my wallet does not delete it—but simply removes it from my device. - No seed phrase is needed anymore. Your Tangem card or ring becomes your secure backup. + I understand that removing my wallet does not delete it, only removes it from my device. + Seed phrase not required. Your Tangem card or ring becomes your secure backup. Backup with Tangem Can’t upgrade. A wallet already exists on this device. Pick another device. This one can’t be used for the upgrade. @@ -639,7 +639,7 @@ Key Migration Scan device Start upgrade - You’re about to upgrade to our hardware wallet. This will keep your assets safe in cold storage. + You\'re about to upgrade to our hardware wallet. It will keep your assets safe in cold storage. Tangem Wallet Upgrade to Hardware Wallet Keep your crypto safe with Tangem\'s top-tier hardware wallet. @@ -726,6 +726,7 @@ Top Gainers Top Losers Trending + Yield Mode Staking is the easiest way to receive rewards on your crypto. %s Earn up to %s APY Token Added @@ -801,7 +802,7 @@ Mobile Wallet requires %1$s or later All news - + %dh ago %dh ago @@ -983,6 +984,7 @@ Other currencies Popular Fiats Search by currency + This transaction has already been processed. No further action is required. Fetching best rates... Instant By using onramp functionality, you agree with provider’s %1$s and %2$s diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 3f46d7909d..33e81eb02f 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt index 8a5cae1ae4..728da8c24c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt @@ -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) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt index e01b50dfb3..9354511464 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt @@ -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() } \ No newline at end of file diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt new file mode 100644 index 0000000000..6e145b2371 --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTopFiveMarketTokenUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTopFiveMarketTokenUseCase.kt new file mode 100644 index 0000000000..c81d7fcbfe --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTopFiveMarketTokenUseCase.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index cadd191a0e..9d6659788e 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -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) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedComponentModule.kt similarity index 92% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ComponentModule.kt rename to features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedComponentModule.kt index 5c06261458..7d5c10f82d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ComponentModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedComponentModule.kt @@ -10,7 +10,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal interface ComponentModule { +internal interface FeedComponentModule { @Binds @Singleton diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt new file mode 100644 index 0000000000..b513310fb1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/MarketsTokenItemConverter.kt @@ -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 { + + 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 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() } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 5445a03eed..6467b4cc3c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -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>, + loadingStatesByOrder: Map, + errorStatesByOrder: Map, + ) { + _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 + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 82cde520e1..6a6b6b2b3f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -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 -> diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt index 69f94e61e6..0d936738f5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedListLoading.kt @@ -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() { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index 747d3d49bc..9cf0dea267 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -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, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 60f842ea9b..519472ae49 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -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, - val triggerScrollReset: StateEvent, val sortChartConfig: SortChartConfigUM, ) : MarketChartUM diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt new file mode 100644 index 0000000000..bdda39948d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedMarketsBatchFlowManager.kt @@ -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, + private val modelScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, +) { + private val managersByOrder = TokenMarketListConfig.Order.entries.associateWith { order -> + createManagerForOrder(order) + } + + val itemsByOrder: StateFlow>> = + 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> = + 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> = + 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>() + + 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? { + return managersByOrder[order]?.onLastBatchLoadedSuccess + } + + private class SingleOrderManager( + val order: TokenMarketListConfig.Order, + private val actionsFlow: MutableSharedFlow>, + private val batchFlow: TokenListBatchFlow, + private val currentAppCurrency: Provider, + 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> = + 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>>, 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>> = emptyList(), + val processedItems: List>>? = 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 + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt index 52bc5e2c2f..f096546b37 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/TrendingNewsStateFactory.kt @@ -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, @@ -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), + ), + ), + ) + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt index ea7e5f740a..27e1c0daaf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/state/MarketsListUM.kt @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts index f6e74fac62..6a05fead77 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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