diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 67dfaf1848..b0dc85a2c9 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -315,6 +315,7 @@ час Импортировать В процессе + Недостаточный баланс Позже Узнать больше Осталось %1$s @@ -337,6 +338,7 @@ NFT Нет Нет адреса + Нет результата Не добавлено Не доступно Не сейчас @@ -484,6 +486,13 @@ Отправка средств в другой сети может повлечь потерю средств. %s сеть Отправляйте средства, используя только + Динамические адреса + Используйте новый адрес для каждой транзакции, чтобы снизить отслеживаемость и повысить конфиденциальность в блокчейне. + Повышенная конфиденциальность + Бесшовное получение + Включить динамические адреса + Динамические адреса недоступны + Сервис недоступен. Пожалуйста, попробуйте еще раз. Лучшие возможности Очистить фильтр Список временно пуст — он обновляется. Пожалуйста, зайдите чуть позже. @@ -701,6 +710,7 @@ Приложите, чтобы отсканировать Приложите, чтобы подписать Приложите карту или кольцо + Кошелёк успешно импортирован Вы обновили данные биометрии, отсканируйте свою карту или кольцо для входа Ваш баланс должен быть выше суммы комиссии для осуществления перевода Недостаточно средств @@ -756,10 +766,14 @@ Нет данных Пульс рынка Быстрые действия + Очистить всё Поиск токенов + Недавние + В вашем портфеле Результат Токены с капитализацией меньше 100к USD Показать токены + Криптовалюты, новости и не только Нет результата Выберите сеть Выберите кошелек @@ -817,6 +831,10 @@ Держатели Изменение количества держателей токенов в течение выбранного периода времени Держатели + Д + М + Н + Г Инсайты Ссылки Ликвидность @@ -835,6 +853,7 @@ Максимальное количество монет или токенов, которое может когда-либо существовать для выбранной криптовалюты Максимальный объем Метрики + Нет ограничений Официальные ссылки Динамика цены Репозиторий @@ -1121,6 +1140,14 @@ Настройки Вы не предоставили доступ к вашей камере Доступ к камере запрещен + Запрашиваемый токен не добавлен в ваш кошелёк. Пожалуйста, добавьте его и попробуйте снова. + Токен не добавлен + Извините, этот QR-код не удалось распознать. + Нераспознанный QR-код + Эта сеть не поддерживается ни одним из добавленных вами токенов. Добавьте поддерживаемый токен для отправки криптовалюты. + Поддерживаемые токены не найдены + Этот QR-код содержит параметры, которые не распознаны: %s. Некоторые данные платежа могут быть утеряны, если вы продолжите. + Неизвестные параметры Memo не требуется %1$s (%2$s) в сети %3$s %1$s в %2$s сети @@ -1239,6 +1266,12 @@ Мемо %s Недопустимый Memo Покрытие сетевой комиссии + + Токен %d не совместим с этим адресом + + Токены %d не совместимы с этим адресом + + Nonce Уникальный номер транзакции. Измените, чтобы ускорить отправку или отменить зависшую операцию. Введите nonce… @@ -1309,6 +1342,7 @@ К получению %s Вы уверены, что хотите отменить конвертацию? Ваши предыдущие данные будут удалены. Убрать конвертацию + Что-то пошло не так. Попробуйте еще раз. Отправка с обменом Транзакция отправлена Подготовьтесь к сканированию кольца или карты, которую вы хотите настроить. @@ -1497,14 +1531,18 @@ Ошибка расчета комиссии. Пожалуйста, отправьте информацию в поддержку. Вы отправляете Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму. + Вы можете получить значительно меньше из-за низкой ликвидности. Попробуйте меньшую сумму или другого провайдера. Высокое влияние на цену Недостаточно средств + Недостаточно средств для завершения этой транзакции. Уменьшите сумму для получения или добавьте больше средств. Дать разрешение Обменять Обмен… Вы получите Выберите токен недоступен + Недостаточно ликвидности для этой сделки.\nУменьшите сумму или выберите другого провайдера. + Сумма сделки слишком велика Будем рады вашей обратной связи Tangem Pay в режиме beta Карта заморожена diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index d3754c9d92..5b19f4c6fd 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -765,11 +765,13 @@ This section’s data is sourced from the following networks: %s Unable to load the data… No data + **Add to your portfolio** to start buying, exchanging or receiving this asset + In your portfolio Market Pulse Quick actions Clear all Search tokens - Recents + Recent\'s In your portfolio Result See tokens under 100k USD market cap diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt index e824de1e5d..8be4dc9343 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/AnnotatedStringExt.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import org.intellij.markdown.MarkdownElementTypes @@ -26,18 +27,23 @@ fun rememberMarkdownParser() = remember { * @param node current processed node */ @Composable -fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode): AnnotatedString.Builder { +fun AnnotatedString.Builder.appendMarkdown( + markdownText: String, + node: ASTNode, + boldColor: Color = Color.Unspecified, +): AnnotatedString.Builder { when (node.type) { MarkdownElementTypes.MARKDOWN_FILE, MarkdownElementTypes.PARAGRAPH -> { node.children.forEach { childNode -> appendMarkdown( markdownText = markdownText, node = childNode, + boldColor = boldColor, ) } } MarkdownElementTypes.STRONG -> { - withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { + withStyle(SpanStyle(fontWeight = FontWeight.Medium, color = boldColor)) { node.children .drop(2) .dropLast(2) @@ -45,6 +51,7 @@ fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode): appendMarkdown( markdownText = markdownText, node = childNode, + boldColor = boldColor, ) } } @@ -56,6 +63,19 @@ fun AnnotatedString.Builder.appendMarkdown(markdownText: String, node: ASTNode): return this } +/** + * Parses [rawString] as markdown and returns an [AnnotatedString] where bold (`**...**`) sections + * are styled with [boldColor]. + */ +@Composable +fun formatAnnotatedWithBoldColor(rawString: String, boldColor: Color): AnnotatedString { + val markdownParser = rememberMarkdownParser() + val parsedTree = markdownParser.parse(MarkdownElementTypes.MARKDOWN_FILE, rawString, true) + return buildAnnotatedString { + appendMarkdown(markdownText = rawString, node = parsedTree, boldColor = boldColor) + } +} + /** * Appends a single space character to the [AnnotatedString.Builder]. */ diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/portfolio/PortfolioComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/portfolio/PortfolioComponent.kt new file mode 100644 index 0000000000..16b7faf015 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/portfolio/PortfolioComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.feed.components.portfolio + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrency + +interface PortfolioComponent : ComposableContentComponent { + + data class Params( + val id: CryptoCurrency.ID, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 752b079b3f..a51d8689f7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.navigation.Route +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent @@ -12,6 +13,7 @@ import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.components.news.list.DefaultNewsListComponent @@ -22,12 +24,15 @@ import com.tangem.features.promobanners.api.PromoBannersBlockComponent import kotlinx.serialization.Serializable import javax.inject.Inject +@Suppress("LongParameterList") internal class FeedEntryChildFactory @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val portfolioComponentFactory: MarketsPortfolioComponent.Factory, + private val portfolioBlockComponentFactory: PortfolioBlockComponent.Factory, private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles, + private val designFeatureToggles: DesignFeatureToggles, ) { @Serializable @@ -77,6 +82,8 @@ internal class FeedEntryChildFactory @Inject constructor( params = child.params, analyticsEventHandler = analyticsEventHandler, portfolioComponentFactory = portfolioComponentFactory, + portfolioBlockComponentFactory = portfolioBlockComponentFactory, + designFeatureToggles = designFeatureToggles, ) } is Child.TokenList -> { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index 3f9c69cdc3..a9c1b09117 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -21,6 +21,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeEffectTangem @@ -35,6 +36,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.state.TokenNetworksState @@ -49,7 +51,9 @@ internal class DefaultMarketsTokenDetailsComponent( appComponentContext: AppComponentContext, val params: Params, analyticsEventHandler: AnalyticsEventHandler, + designFeatureToggles: DesignFeatureToggles, portfolioComponentFactory: MarketsPortfolioComponent.Factory, + portfolioBlockComponentFactory: PortfolioBlockComponent.Factory, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { // applying l2 compatibility @@ -61,25 +65,41 @@ internal class DefaultMarketsTokenDetailsComponent( private val analyticsParams = params.analyticsParams private val model: MarketsTokenDetailsModel = getOrCreateModel(updatedParams) - private val portfolioComponent: MarketsPortfolioComponent? = if (updatedParams.shouldShowPortfolio) { - portfolioComponentFactory.create( - context = child("my_portfolio"), - params = MarketsPortfolioComponent.Params( - updatedParams.token, - analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) }, - ), - ) - } else { - null - } + private val portfolioComponent: MarketsPortfolioComponent? = + if (updatedParams.shouldShowPortfolio && !designFeatureToggles.isRedesignEnabled) { + portfolioComponentFactory.create( + context = child("my_portfolio"), + params = MarketsPortfolioComponent.Params( + updatedParams.token, + analyticsParams = analyticsParams?.source?.let { MarketsPortfolioComponent.AnalyticsParams(it) }, + ), + ) + } else { + null + } + + private val portfolioBlockComponent: PortfolioBlockComponent? = + if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) { + portfolioBlockComponentFactory.create( + context = child("portfolio_block"), + params = PortfolioBlockComponent.Params(updatedParams.token), + ) + } else { + null + } init { - componentScope.launch(dispatchers.default) { model.networksState.collectLatest { state -> when (state) { - is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(state.networks) - TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() + is TokenNetworksState.NetworksAvailable -> { + portfolioBlockComponent?.setTokenNetworks(state.networks) + portfolioComponent?.setTokenNetworks(state.networks) + } + TokenNetworksState.NoNetworksAvailable -> { + portfolioBlockComponent?.setNoNetworksAvailable() + portfolioComponent?.setNoNetworksAvailable() + } else -> {} } } @@ -192,6 +212,11 @@ internal class DefaultMarketsTokenDetailsComponent( component.Content(blockModifier) } }, + portfolioFloatingBlock = portfolioBlockComponent?.let { component -> + { blockModifier -> + component.Content(blockModifier) + } + }, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt index 18aa2ea032..9e689855ae 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt @@ -7,6 +7,7 @@ import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import kotlinx.serialization.Serializable +@Deprecated("Will be removed in favor of PortfolioComponent with redesigned UI") @Stable interface MarketsPortfolioComponent : ComposableContentComponent { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt new file mode 100644 index 0000000000..22f52fccc7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt @@ -0,0 +1,115 @@ +package com.tangem.features.feed.components.market.details.portfolioblock + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockModel +import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockRoute +import com.tangem.features.feed.components.market.details.portfolioblock.ui.PortfolioBlock +import com.tangem.features.feed.components.portfolio.PortfolioComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable + +@Stable +internal class PortfolioBlockComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : ComposableContentComponent, AppComponentContext by context { + + private val portfolioComponentFactory: PortfolioComponent.Factory = object : PortfolioComponent.Factory { + override fun create(context: AppComponentContext, params: PortfolioComponent.Params): PortfolioComponent { + TODO("STUB. Will be implemented") + } + } + + @Serializable + data class Params( + val token: TokenMarketParams, + ) + + private val model: PortfolioBlockModel = getOrCreateModel(params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = PortfolioBlockRoute.serializer(), + handleBackButton = true, + childFactory = ::createBottomSheetChild, + ) + + fun setTokenNetworks(networks: List) { + model.setTokenNetworks(networks) + } + + fun setNoNetworksAvailable() { + model.setNoNetworksAvailable() + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + PortfolioBlock(modifier = modifier, state = state) + bottomSheet.child?.instance?.BottomSheet() + } + + @Suppress("UnusedParameter") + private fun createBottomSheetChild( + config: PortfolioBlockRoute, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + val currencyId = model.cryptoCurrencyIdState.value ?: return ComposableBottomSheetComponent.EMPTY + val portfolioComponent = portfolioComponentFactory.create( + context = childByContext(componentContext), + params = PortfolioComponent.Params(id = currencyId), + ) + return PortfolioBottomSheetWrapper( + portfolioComponent = portfolioComponent, + onDismiss = { model.bottomSheetNavigation.dismiss() }, + ) + } + + private class PortfolioBottomSheetWrapper( + private val portfolioComponent: PortfolioComponent, + private val onDismiss: () -> Unit, + ) : ComposableBottomSheetComponent { + + override fun dismiss() = onDismiss() + + @Composable + override fun BottomSheet() { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + ) { + portfolioComponent.Content(modifier = Modifier) + } + } + } + + @AssistedFactory + interface Factory { + fun create(context: AppComponentContext, params: Params): PortfolioBlockComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/di/PortfolioBlockModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/di/PortfolioBlockModelModule.kt new file mode 100644 index 0000000000..d2009383c0 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/di/PortfolioBlockModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolioblock.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface PortfolioBlockModelModule { + + @Binds + @IntoMap + @ClassKey(PortfolioBlockModel::class) + fun providePortfolioBlockModel(model: PortfolioBlockModel): Model +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt new file mode 100644 index 0000000000..48901a9e3c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt @@ -0,0 +1,188 @@ +package com.tangem.features.feed.components.market.details.portfolioblock.model + +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.SpanStyle +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.common.getTotalFiatAmount +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent +import com.tangem.features.feed.components.market.details.portfolioblock.ui.state.PortfolioBlockUM +import com.tangem.features.feed.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* +import java.math.BigDecimal +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@Stable +@ModelScoped +internal class PortfolioBlockModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val allAccountSupplier: MultiAccountStatusListSupplier, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, +) : Model() { + + val state: StateFlow + field = MutableStateFlow(PortfolioBlockUM.Loading) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + val cryptoCurrencyIdState: StateFlow + field = MutableStateFlow(null) + + private val params = paramsContainer.require() + private val currencyRawId: CryptoCurrency.RawID = params.token.id + + private val tokenIcon: CurrencyIconState = CurrencyIconState.CoinIcon( + url = params.token.imageUrl, + fallbackResId = R.drawable.ic_custom_token_44, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + + private val availableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + init { + combineData() + .onEach { state.value = it } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + fun setTokenNetworks(networks: List) { + availableNetworks.tryEmit(networks) + } + + fun setNoNetworksAvailable() { + availableNetworks.tryEmit(emptyList()) + } + + private fun combineData(): Flow { + return availableNetworks.transformLatest { networks -> + if (networks.isEmpty()) { + emit(PortfolioBlockUM.Hidden) + } else { + emitAll(portfolioFlow().distinctUntilChanged()) + } + }.distinctUntilChanged() + } + + private fun portfolioFlow(): Flow { + val portfolioDataFlow = allAccountSupplier().flatMapLatest { accountStatusLists -> + val walletFlows = accountStatusLists.map { accountStatusList -> + getUserWalletUseCase.invokeFlow(accountStatusList.userWalletId) + .mapNotNull { it.getOrNull() } + .map { wallet -> + WalletPortfolio( + userWallet = wallet, + currencies = accountStatusList.filterByRawId(), + ) + } + } + if (walletFlows.isEmpty()) { + flowOf(emptyList()) + } else { + combine(walletFlows) { it.toList() } + } + }.distinctUntilChanged() + + val settingsFlow = combine( + getSelectedAppCurrencyUseCase.invokeOrDefault(), + getBalanceHidingSettingsUseCase.isBalanceHidden(), + ) { appCurrency, isBalanceHidden -> + SettingsBox(appCurrency, isBalanceHidden) + }.distinctUntilChanged() + + return combine(portfolioDataFlow, settingsFlow) { portfolios, settings -> + buildState(portfolios, settings) + }.distinctUntilChanged() + } + + private fun AccountStatusList.filterByRawId(): List { + return accountStatuses.filterCryptoPortfolio().flatMap { accountStatus -> + accountStatus.tokenList.flattenCurrencies().filter { status -> + val statusRawId = status.currency.id.rawCurrencyId ?: return@filter false + getTokenIdIfL2Network(statusRawId.value) == currencyRawId.value + } + } + } + + private fun buildState(portfolios: List, settings: SettingsBox): PortfolioBlockUM { + val allCurrencies = portfolios.flatMap { it.currencies } + val hasMultiCurrencyWallet = portfolios.any { it.userWallet.isMultiCurrency } + + if (allCurrencies.isEmpty()) { + return if (hasMultiCurrencyWallet) { + PortfolioBlockUM.AddToken( + tokenIcon = tokenIcon, + onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) }, + ) + } else { + PortfolioBlockUM.Hidden + } + } + + cryptoCurrencyIdState.update { + it ?: allCurrencies.first().currency.id + } + + val totalFiat = allCurrencies.mapNotNull { it.getTotalFiatAmount() } + .fold(BigDecimal.ZERO, BigDecimal::add) + + val formattedBalance = totalFiat.formatStyled { + fiat( + fiatCurrencyCode = settings.appCurrency.code, + fiatCurrencySymbol = settings.appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ) + } + + return PortfolioBlockUM.Content( + totalBalance = formattedBalance, + tokensInPortfolioCount = allCurrencies.size, + tokenIcon = tokenIcon, + tokenName = allCurrencies.first().currency.name, + tokenSymbol = allCurrencies.first().currency.symbol, + isBalanceHidden = settings.isBalanceHidden, + onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) }, + ) + } +} + +private data class WalletPortfolio( + val userWallet: UserWallet, + val currencies: List, +) + +private data class SettingsBox( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt new file mode 100644 index 0000000000..4c1f19107b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.components.market.details.portfolioblock.model + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal data object PortfolioBlockRoute : Route \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt new file mode 100644 index 0000000000..9f7cb34c98 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt @@ -0,0 +1,266 @@ +package com.tangem.features.feed.components.market.details.portfolioblock.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.BottomFade +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.button.* +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.formatAnnotatedWithBoldColor +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalWindowSize +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.feed.components.market.details.portfolioblock.ui.state.PortfolioBlockUM +import com.tangem.features.feed.impl.R + +@Composable +internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifier) { + val isVisible = state is PortfolioBlockUM.AddToken || state is PortfolioBlockUM.Content + val screenHeightPx = with(LocalDensity.current) { LocalWindowSize.current.height.toPx() } + + Box(modifier = modifier) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 300)), + ) { + BottomFade( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + } + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)), + exit = fadeOut(animationSpec = tween(durationMillis = 300)), + ) { + val slideOffset by transition.animateFloat( + transitionSpec = { + tween( + durationMillis = 600, + easing = CubicBezierEasing(a = 0.83f, b = 0f, c = 0.17f, d = 1f), + ) + }, + label = "slideOffset", + ) { enterExitState -> + when (enterExitState) { + EnterExitState.PreEnter -> screenHeightPx + EnterExitState.Visible -> 0f + EnterExitState.PostExit -> 0f + } + } + + Box(modifier = Modifier.graphicsLayer { translationY = slideOffset }) { + when (state) { + is PortfolioBlockUM.AddToken -> AddTokenBlock(state) + is PortfolioBlockUM.Content -> ContentBlock(state) + is PortfolioBlockUM.Hidden, + is PortfolioBlockUM.Loading, + -> Unit + } + } + } + } +} + +@Composable +private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = Modifier) { + FloatingCard( + modifier = modifier, + onClick = state.onClick, + ) { + TangemRowContainer( + contentPadding = PaddingValues(0.dp), + ) { + CurrencyIcon( + modifier = Modifier + .padding(end = TangemTheme.dimens2.x3) + .layoutId(TangemRowLayoutId.HEAD), + state = state.tokenIcon, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = state.tokenName, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = stringResourceSafe(R.string.markets_portfolio_block_subtitle), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.bodyMedium16, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), + text = state.tokenSymbol, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + + TangemButton( + modifier = Modifier + .padding(start = TangemTheme.dimens2.x3) + .layoutId(TangemRowLayoutId.TAIL), + buttonUM = TangemButtonUM( + type = TangemButtonType.Secondary, + iconRes = R.drawable.ic_chevron_24, + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X9, + onClick = state.onClick, + ), + ) + } + } +} + +@Composable +private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = Modifier) { + FloatingCard( + modifier = modifier, + onClick = state.onClick, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CurrencyIcon(state.tokenIcon) + + SpacerW(TangemTheme.dimens2.x3) + + Text( + text = formatAnnotatedWithBoldColor( + rawString = stringResourceSafe(R.string.markets_portfolio_block_add_token_title), + boldColor = TangemTheme.colors2.text.neutral.primary, + ), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier.weight(1f), + maxLines = 2, + ) + + SpacerW(TangemTheme.dimens2.x2) + + TangemButton( + buttonUM = TangemButtonUM( + type = TangemButtonType.Secondary, + text = resourceReference(R.string.common_add), + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X9, + onClick = state.onClick, + ), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FloatingCard(modifier: Modifier = Modifier, onClick: () -> Unit = {}, content: @Composable () -> Unit) { + Box( + modifier = modifier + .navigationBarsPadding() + .fillMaxWidth() + .widthIn(max = BottomSheetDefaults.SheetMaxWidth) + .padding( + start = TangemTheme.dimens2.x2, + end = TangemTheme.dimens2.x2, + bottom = TangemTheme.dimens2.x2, + ) + .clip(RoundedCornerShape(size = TangemTheme.dimens2.x5)) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(size = TangemTheme.dimens2.x5), + ) + .clickable(onClick = onClick) + .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3), + ) { + content() + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreviewRedesign { + PortfolioBlock( + state = PortfolioBlockUM.Content( + totalBalance = stringReference("$1,234.56"), + tokensInPortfolioCount = 3, + tokenIcon = previewCoinIcon, + tokenName = "Bitcoin", + tokenSymbol = "BTC", + isBalanceHidden = false, + onClick = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AddTokenPreview() { + TangemThemePreviewRedesign { + PortfolioBlock( + state = PortfolioBlockUM.AddToken( + tokenIcon = previewCoinIcon, + onClick = {}, + ), + ) + } +} + +private val previewCoinIcon + get() = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt new file mode 100644 index 0000000000..f61b90aa05 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt @@ -0,0 +1,27 @@ +package com.tangem.features.feed.components.market.details.portfolioblock.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal sealed class PortfolioBlockUM { + + data object Loading : PortfolioBlockUM() + data object Hidden : PortfolioBlockUM() + + data class AddToken( + val tokenIcon: CurrencyIconState, + val onClick: () -> Unit, + ) : PortfolioBlockUM() + + data class Content( + val totalBalance: TextReference, + val tokensInPortfolioCount: Int, + val tokenIcon: CurrencyIconState, + val tokenName: String, + val tokenSymbol: String, + val isBalanceHidden: Boolean, + val onClick: () -> Unit, + ) : PortfolioBlockUM() +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index 48b2e71786..195e45cf2f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -12,16 +12,22 @@ import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.SpacerH4 @@ -64,6 +70,7 @@ internal fun MarketsTokenDetailsContent( backgroundColor: Color, modifier: Modifier = Modifier, portfolioBlock: @Composable ((Modifier) -> Unit)?, + portfolioFloatingBlock: @Composable ((Modifier) -> Unit)?, ) { Content( contentPadding = contentPadding, @@ -71,6 +78,7 @@ internal fun MarketsTokenDetailsContent( backgroundColor = backgroundColor, state = state, portfolioBlock = portfolioBlock, + portfolioFloatingBlock = portfolioFloatingBlock, ) when (state.bottomSheetConfig.content) { @@ -80,7 +88,7 @@ internal fun MarketsTokenDetailsContent( } } -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LongMethod") @Composable private fun Content( contentPadding: PaddingValues, @@ -88,6 +96,7 @@ private fun Content( backgroundColor: Color, modifier: Modifier = Modifier, portfolioBlock: @Composable ((Modifier) -> Unit)?, + portfolioFloatingBlock: @Composable ((Modifier) -> Unit)?, ) { val isRedesignEnabled = LocalRedesignEnabled.current val density = LocalDensity.current @@ -97,54 +106,71 @@ private fun Content( lazyListState = lazyListState, onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange, ) + var bottomSpacing by remember { mutableStateOf(0.dp) } - Column( - modifier = modifier + Box( + modifier .drawBehind { drawRect(backgroundColor) } .fillMaxSize(), ) { - SpacerH4() + Column { + SpacerH4() - LazyColumn( - state = lazyListState, - contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()), - ) { - item("header") { - Header( - state = state, - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - ) - } - item { SpacerH16() } - item("intervalSelector") { - IntervalSelector( - trendInterval = state.selectedInterval, - onIntervalClick = state.onSelectedIntervalChange, - isEnabled = state.body !is MarketsTokenDetailsUM.Body.Nothing, - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - ) - } - item { SpacerH32() } - item("chart") { - MarketTokenDetailsChart( - modifier = Modifier.fillMaxWidth(), - backgroundColor = backgroundColor, - state = state.chartState, - ) - } - item { SpacerH16() } + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()), + ) { + item("header") { + Header( + state = state, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + item { SpacerH16() } + item("intervalSelector") { + IntervalSelector( + trendInterval = state.selectedInterval, + onIntervalClick = state.onSelectedIntervalChange, + isEnabled = state.body !is MarketsTokenDetailsUM.Body.Nothing, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + item { SpacerH32() } + item("chart") { + MarketTokenDetailsChart( + modifier = Modifier.fillMaxWidth(), + backgroundColor = backgroundColor, + state = state.chartState, + ) + } + item { SpacerH16() } - tokenMarketDetailsBody( - state = state.body, - portfolioBlock = portfolioBlock, - relatedNews = state.relatedNews, - isRedesignEnabled = isRedesignEnabled, - ) + tokenMarketDetailsBody( + state = state.body, + portfolioBlock = portfolioBlock, + relatedNews = state.relatedNews, + isRedesignEnabled = isRedesignEnabled, + ) + + item { SpacerH(bottomSpacing) } + } } + + portfolioFloatingBlock?.invoke( + Modifier + .onSizeChanged { size -> + bottomSpacing = if (size.height > 0) { + with(density) { size.height.toDp() + 16.dp } + } else { + 0.dp + } + } + .align(Alignment.BottomCenter), + ) } } @@ -404,6 +430,7 @@ private fun MarketsTokenDetailsContent_Preview( state = params, backgroundColor = TangemTheme.colors.background.tertiary, portfolioBlock = {}, + portfolioFloatingBlock = null, contentPadding = PaddingValues(), ) }