diff --git a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt index 55b1457a55..a49eceb64c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NewsDomainModule.kt @@ -6,39 +6,43 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal object NewsDomainModule { @Provides - @Singleton fun provideGetNewsCategoriesUseCase(repository: NewsRepository): GetNewsCategoriesUseCase { return GetNewsCategoriesUseCase(repository) } @Provides - @Singleton fun provideObserveNewsDetailsUseCase(repository: NewsRepository): ObserveNewsDetailsUseCase { return ObserveNewsDetailsUseCase(repository) } @Provides - @Singleton fun provideObserveTrendingNewsUseCase(repository: NewsRepository): ManageTrendingNewsUseCase { return ManageTrendingNewsUseCase(repository) } @Provides - @Singleton fun provideGetNewsListBatchFlowUseCase(repository: NewsRepository): GetNewsListBatchFlowUseCase { return GetNewsListBatchFlowUseCase(repository) } @Provides - @Singleton fun provideFetchTrendingNewsUseCase(repository: NewsRepository): FetchTrendingNewsUseCase { return FetchTrendingNewsUseCase(repository) } + + @Provides + fun provideMarkArticleAsViewedUseCase(repository: NewsRepository): MarkArticleAsViewedUseCase { + return MarkArticleAsViewedUseCase(repository) + } + + @Provides + fun provideGetNewsUseCase(repository: NewsRepository): GetNewsUseCase { + return GetNewsUseCase(repository) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt index fa1dbd3792..33bd6b280a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NewsStoreModule.kt @@ -5,6 +5,8 @@ import com.tangem.datasource.local.news.details.DefaultNewsDetailsStore import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.DefaultTrendingNewsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.datasource.local.news.viewed.DefaultNewsViewedStore +import com.tangem.datasource.local.news.viewed.NewsViewedStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -26,4 +28,10 @@ internal object NewsStoreModule { fun provideTrendingNewsStore(): TrendingNewsStore { return DefaultTrendingNewsStore(store = RuntimeSharedStore()) } + + @Provides + @Singleton + fun provideNewsViewedStore(): NewsViewedStore { + return DefaultNewsViewedStore(store = RuntimeSharedStore()) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt new file mode 100644 index 0000000000..f365d8a09e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/DefaultNewsViewedStore.kt @@ -0,0 +1,32 @@ +package com.tangem.datasource.local.news.viewed + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onStart + +private typealias NewsViewedCache = Map + +internal class DefaultNewsViewedStore( + private val store: RuntimeSharedStore, +) : NewsViewedStore { + + override fun getAll(): Flow> { + return store.get().onStart { emit(emptyMap()) } + } + + override suspend fun getSync(): Map { + return store.getSyncOrNull().orEmpty() + } + + override suspend fun updateViewed(articleIds: Collection, viewed: Boolean) { + if (articleIds.isEmpty()) return + + store.update(emptyMap()) { current -> + val updated = current.toMutableMap() + articleIds.forEach { id -> + updated[id] = viewed + } + updated + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt new file mode 100644 index 0000000000..24a2fdc39b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/news/viewed/NewsViewedStore.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.local.news.viewed + +import kotlinx.coroutines.flow.Flow + +/** + * Store for news viewed flags (runtime only). + */ +interface NewsViewedStore { + + /** + * Observes all viewed flags. + */ + fun getAll(): Flow> + + /** + * Gets viewed flags synchronously (returns empty map if no data). + */ + suspend fun getSync(): Map + + /** + * Updates viewed flags for provided article ids. + */ + suspend fun updateViewed(articleIds: Collection, viewed: Boolean) +} \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt index 9a5aa274f1..cb558a2283 100644 --- a/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt +++ b/data/news/src/main/java/com/tangem/data/news/di/NewsDataModule.kt @@ -4,6 +4,7 @@ import com.tangem.data.news.repository.DefaultNewsRepository import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.datasource.local.news.viewed.NewsViewedStore import com.tangem.domain.news.repository.NewsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -23,12 +24,14 @@ internal object NewsDataModule { dispatchers: CoroutineDispatcherProvider, newsDetailsStore: NewsDetailsStore, trendingNewsStore: TrendingNewsStore, + newsViewedStore: NewsViewedStore, ): NewsRepository { return DefaultNewsRepository( newsApi = newsApi, dispatchers = dispatchers, newsDetailsStore = newsDetailsStore, trendingNewsStore = trendingNewsStore, + newsViewedStore = newsViewedStore, ) } } \ No newline at end of file diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 09cc6015b6..96fe7bd6d8 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -7,6 +7,7 @@ import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.news.models.response.NewsTrendingResponse import com.tangem.datasource.local.news.details.NewsDetailsStore import com.tangem.datasource.local.news.trending.TrendingNewsStore +import com.tangem.datasource.local.news.viewed.NewsViewedStore import com.tangem.domain.models.news.* import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext @@ -23,6 +24,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import timber.log.Timber @@ -36,6 +38,7 @@ internal class DefaultNewsRepository( private val dispatchers: CoroutineDispatcherProvider, private val newsDetailsStore: NewsDetailsStore, private val trendingNewsStore: TrendingNewsStore, + private val newsViewedStore: NewsViewedStore, ) : NewsRepository { override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { @@ -47,6 +50,27 @@ internal class DefaultNewsRepository( ).toBatchFlow() } + override suspend fun getNews(config: NewsListConfig, limit: Int): List { + return withContext(dispatchers.io) { + val response = newsApi.getNews( + page = FIRST_PAGE, + limit = limit, + language = config.language, + snapshot = config.snapshot, + tokenIds = config.tokenIds.takeIf { it.isNotEmpty() }, + categoryIds = config.categoryIds.takeIf { it.isNotEmpty() }, + ).getOrThrow() + + val articles = response.items.map { it.toDomainShortArticle() } + val viewedFlags = newsViewedStore.getSync() + + articles.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } + } + } + override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle { val cached = newsDetailsStore.getSyncOrNull(newsId) if (cached != null) return cached @@ -73,29 +97,21 @@ internal class DefaultNewsRepository( } override fun observeTrendingNews(): Flow { - return trendingNewsStore.get(TRENDING_NEWS_KEY) - } - - override suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) { - if (articleIds.isEmpty()) return - - val currentResult = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) ?: return - val currentArticles = when (currentResult) { - is TrendingNews.Data -> currentResult.articles - is TrendingNews.Error -> return - } - if (currentArticles.isEmpty()) return - - val ids = articleIds.toSet() - val updated = currentArticles.map { article -> - if (article.id in ids) { - article.copy(viewed = viewed) - } else { - article + return combine( + trendingNewsStore.get(TRENDING_NEWS_KEY), + newsViewedStore.getAll(), + ) { trendingNews, viewedFlags -> + when (trendingNews) { + is TrendingNews.Data -> { + val articlesWithViewedFlags = trendingNews.articles.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } + TrendingNews.Data(articlesWithViewedFlags) + } + is TrendingNews.Error -> trendingNews } } - - trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(updated)) } override suspend fun getCategories(): List { @@ -107,6 +123,10 @@ internal class DefaultNewsRepository( } } + override suspend fun updateNewsViewed(articleIds: Collection, viewed: Boolean) { + newsViewedStore.updateViewed(articleIds, viewed) + } + private suspend fun fetchDetailedArticlesInternal(newsIds: Collection, language: String?) = withContext(dispatchers.io) { if (newsIds.isEmpty()) return@withContext @@ -167,41 +187,26 @@ internal class DefaultNewsRepository( } is ApiResponse.Success -> { val freshArticles = result.data.items.map { it.toDomainShortArticle() } - val cachedArticles = trendingNewsStore.getSyncOrNull(TRENDING_NEWS_KEY) - val currentArticles = when (cachedArticles) { - is TrendingNews.Data -> cachedArticles.articles - is TrendingNews.Error -> emptyList() - null -> emptyList() - } - val merged = mergeTrendingArticles(current = currentArticles, fresh = freshArticles).take(limit) - trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(merged)) - TrendingNews.Data(merged) + val articles = freshArticles.take(limit) + trendingNewsStore.store(TRENDING_NEWS_KEY, TrendingNews.Data(articles)) + TrendingNews.Data(articles) } } } } - private fun mergeTrendingArticles(current: List, fresh: List): List { - if (current.isEmpty()) return fresh - - val currentById = current.associateBy(ShortArticle::id) - - return fresh.map { article -> - val stored = currentById[article.id] ?: return@map article - article.copy(viewed = stored.viewed) - } - } - private fun createBatchFetcher(batchSize: Int): BatchFetcher> { return NewsBatchFetcher( newsApi = newsApi, batchSize = batchSize, + newsViewedStore = newsViewedStore, ) } private class NewsBatchFetcher( private val newsApi: NewsApi, private val batchSize: Int, + private val newsViewedStore: NewsViewedStore, ) : BatchFetcher> { private var state: NewsPaginationState? = null @@ -271,7 +276,13 @@ internal class DefaultNewsRepository( categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, ).getOrThrow() - val items = response.items.map { it.toDomainShortArticle() } + val articles = response.items.map { it.toDomainShortArticle() } + val viewedFlags = newsViewedStore.getSync() + + val items = articles.map { article -> + val isViewed = viewedFlags[article.id] == true + article.copy(viewed = isViewed) + } val batchResult = BatchFetchResult.Success( data = items, diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt index f9f69c5b22..67423a773a 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt @@ -15,7 +15,7 @@ import kotlinx.serialization.Serializable @Serializable data class NewsListConfig( val language: String, - val snapshot: String, + val snapshot: String?, val tokenIds: List = emptyList(), val categoryIds: List = emptyList(), ) \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index 3b9e06ce82..2d2e5043a0 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -2,9 +2,11 @@ package com.tangem.domain.news.repository import com.tangem.domain.models.news.ArticleCategory import com.tangem.domain.models.news.DetailedArticle +import com.tangem.domain.models.news.ShortArticle import com.tangem.domain.models.news.TrendingNews import com.tangem.domain.news.model.NewsListBatchFlow import com.tangem.domain.news.model.NewsListBatchingContext +import com.tangem.domain.news.model.NewsListConfig import kotlinx.coroutines.flow.Flow /** @@ -20,6 +22,13 @@ interface NewsRepository { */ fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow + /** + * Returns list of short article by config. + * + * @param config config for getting news list + */ + suspend fun getNews(config: NewsListConfig, limit: Int): List + /** * Returns detailed article by id with locale configuration. * @param newsId news identification @@ -50,13 +59,13 @@ interface NewsRepository { */ fun observeTrendingNews(): Flow - /** - * Updates viewed flag for provided trending articles. - */ - suspend fun updateTrendingNewsViewed(articleIds: Collection, viewed: Boolean) - /** * Returns available categories. */ suspend fun getCategories(): List + + /** + * Updates viewed flag for provided news articles (applies to both regular and trending news). + */ + suspend fun updateNewsViewed(articleIds: Collection, viewed: Boolean) } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt new file mode 100644 index 0000000000..74d8533a59 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/GetNewsUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.news.usecase + +import arrow.core.Either +import com.tangem.domain.models.news.ShortArticle +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.repository.NewsRepository + +class GetNewsUseCase(private val repository: NewsRepository) { + + suspend fun getNews(limit: Int, newsListConfig: NewsListConfig): Either> = + Either.catch { + repository.getNews( + config = newsListConfig, + limit = limit, + ) + } +} \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt index 6d76aaa9f6..113bf347aa 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ManageTrendingNewsUseCase.kt @@ -20,18 +20,4 @@ class ManageTrendingNewsUseCase(private val repository: NewsRepository) { .observeTrendingNews() .distinctUntilChanged() } - - /** - * Marks a single article as viewed/unviewed. - */ - suspend fun markAsViewed(articleId: Int, viewed: Boolean = true) { - repository.updateTrendingNewsViewed(listOf(articleId), viewed) - } - - /** - * Marks multiple articles at once (useful for bulk updates). - */ - suspend fun markAsViewed(articleIds: Collection, viewed: Boolean = true) { - repository.updateTrendingNewsViewed(articleIds, viewed) - } } \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt new file mode 100644 index 0000000000..523694c2d5 --- /dev/null +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/MarkArticleAsViewedUseCase.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.news.usecase + +import com.tangem.domain.news.repository.NewsRepository + +class MarkArticleAsViewedUseCase(private val repository: NewsRepository) { + + /** + * Marks a single article as viewed/unviewed. + */ + suspend fun markAsViewed(articleId: Int, viewed: Boolean = true) { + repository.updateNewsViewed(listOf(articleId), viewed) + } + + /** + * Marks multiple articles at once (useful for bulk updates). + */ + suspend fun markAsViewed(articleIds: Collection, viewed: Boolean = true) { + repository.updateNewsViewed(articleIds, viewed) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 8b1cc33975..dead2e7970 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -10,13 +10,11 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.Value -import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent -import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent @@ -34,8 +32,6 @@ import dagger.assisted.AssistedInject internal class DefaultFeedEntryComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted entryRoute: FeedEntryRoute?, - analyticsEventHandler: AnalyticsEventHandler, - accountsFeatureToggles: AccountsFeatureToggles, private val feedEntryChildFactory: FeedEntryChildFactory, ) : FeedEntryComponent, AppComponentContext by context { @@ -101,8 +97,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( router = innerRouter, ), feedEntryClickIntents = clickIntents, - analyticsEventHandler = analyticsEventHandler, - accountsFeatureToggles = accountsFeatureToggles, ) }, ) 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 3cbe7851d2..da730fb1a2 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 @@ -8,13 +8,18 @@ import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent 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 import kotlinx.serialization.Serializable import javax.inject.Inject -internal class FeedEntryChildFactory @Inject constructor() { +internal class FeedEntryChildFactory @Inject constructor( + private val analyticsEventHandler: AnalyticsEventHandler, + private val accountsFeatureToggles: AccountsFeatureToggles, + private val portfolioComponentFactory: MarketsPortfolioComponent.Factory, +) { @Serializable @Immutable @@ -45,8 +50,6 @@ internal class FeedEntryChildFactory @Inject constructor() { child: Child, appComponentContext: AppComponentContext, feedEntryClickIntents: FeedEntryClickIntents, - analyticsEventHandler: AnalyticsEventHandler, - accountsFeatureToggles: AccountsFeatureToggles, ): ComposableModularBottomSheetContentComponent { return when (child) { is Child.TokenDetails -> { @@ -55,6 +58,7 @@ internal class FeedEntryChildFactory @Inject constructor() { params = child.params, analyticsEventHandler = analyticsEventHandler, accountsFeatureToggles = accountsFeatureToggles, + portfolioComponentFactory = portfolioComponentFactory, ) } 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 a870fb007c..ed0f3f488a 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 @@ -10,6 +10,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent @@ -19,10 +20,14 @@ import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles 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.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent +import com.tangem.features.feed.model.market.details.state.TokenNetworksState import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable internal class DefaultMarketsTokenDetailsComponent( @@ -30,7 +35,7 @@ internal class DefaultMarketsTokenDetailsComponent( val params: Params, analyticsEventHandler: AnalyticsEventHandler, private val accountsFeatureToggles: AccountsFeatureToggles, - // TODO add portfolio in migrate [REDACTED_JIRA] + portfolioComponentFactory: MarketsPortfolioComponent.Factory, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { // applying l2 compatibility @@ -42,7 +47,30 @@ 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 + } + init { + + componentScope.launch(dispatchers.default) { + model.networksState.collectLatest { state -> + when (state) { + is TokenNetworksState.NetworksAvailable -> portfolioComponent?.setTokenNetworks(state.networks) + TokenNetworksState.NoNetworksAvailable -> portfolioComponent?.setNoNetworksAvailable() + else -> {} + } + } + } + // === Analytics === if (analyticsParams != null) { analyticsEventHandler.send( @@ -88,8 +116,10 @@ internal class DefaultMarketsTokenDetailsComponent( backgroundColor = LocalMainBottomSheetColor.current.value, state = state, isAccountEnabled = accountsFeatureToggles.isFeatureEnabled, - portfolioBlock = { - // TODO add portfolio in migrate [REDACTED_JIRA] + portfolioBlock = portfolioComponent?.let { component -> + { blockModifier -> + component.Content(blockModifier) + } }, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt new file mode 100644 index 0000000000..38afdb8400 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent + +internal interface AddToPortfolioComponent : ComposableBottomSheetComponent { + + data class Params( + val addToPortfolioManager: AddToPortfolioManager, + val callback: Callback, + ) + + interface Callback { + fun onDismiss() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt new file mode 100644 index 0000000000..bcad1f6cb9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AddToPortfolioManager.kt @@ -0,0 +1,38 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.api + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +internal interface AddToPortfolioManager { + + val token: TokenMarketParams + val analyticsParams: MarketsPortfolioComponent.AnalyticsParams? + val portfolioFetcher: PortfolioFetcher + + val state: StateFlow + + val allAvailableNetworks: Flow> + fun setTokenNetworks(networks: List) + + sealed interface State { + data object Init : State + data class AvailableToAdd( + val availableToAddData: AvailableToAddData, + ) : State + + data object NothingToAdd : State + } + + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + ): AddToPortfolioManager + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt new file mode 100644 index 0000000000..94006d470e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/api/AvailableToAddData.kt @@ -0,0 +1,58 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.api + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +internal data class AvailableToAddData( + val availableToAddWallets: Map, +) { + val isAvailableToAdd: Boolean + get() = availableToAddWallets.isNotEmpty() + val isSinglePortfolio: Boolean + get() = availableToAddWallets.size == 1 && availableToAddWallets.values.first().accounts.size == 1 +} + +internal data class AvailableToAddWallet( + val userWallet: UserWallet, + val accounts: List, + val availableNetworks: Set, + val availableToAddAccounts: Map, +) + +@Serializable +internal data class AvailableToAddAccount( + val account: AccountStatus, + val availableNetworks: Set, + val addedNetworks: Set, +) { + val isSingleNetwork: Boolean + get() = availableNetworks.size == 1 + + val availableToAddNetworks: Set = availableNetworks + .filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } } + .toSet() + + val addedMarketNetworks: Set = availableNetworks + .filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } } + .toSet() +} + +@Serializable +internal data class SelectedPortfolio( + val userWallet: UserWallet, + val account: AvailableToAddAccount, + val isAccountMode: Boolean, + val isAvailableMorePortfolio: Boolean, +) + +internal data class SelectedNetwork( + val selectedNetwork: TokenMarketInfo.Network, + val cryptoCurrency: CryptoCurrency, + val isAvailableMoreNetwork: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt new file mode 100644 index 0000000000..808dcceab5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt @@ -0,0 +1,55 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.addtoken.AddTokenContent +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class AddTokenComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: AddTokenModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val um = state.value ?: return + AddTokenContent( + modifier = modifier, + state = um, + ) + } + + data class Params( + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val selectedPortfolio: Flow, + val selectedNetwork: Flow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onChangeNetworkClick() + fun onChangePortfolioClick() + fun onTokenAdded(status: CryptoCurrencyStatus) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): AddTokenComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt new file mode 100644 index 0000000000..727f08f7cf --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt @@ -0,0 +1,45 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.ChooseNetworkContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class ChooseNetworkComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: ChooseNetworkModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + ChooseNetworkContent(state) + } + + data class Params( + val selectedPortfolio: SelectedPortfolio, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onNetworkSelected(network: TokenMarketInfo.Network) + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): ChooseNetworkComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt new file mode 100644 index 0000000000..174bdcf21b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt @@ -0,0 +1,212 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.backStack +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +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.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes +import com.tangem.features.feed.impl.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddToPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: AddToPortfolioComponent.Params, + portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, + addTokenComponentFactory: AddTokenComponent.Factory, + tokenActionsComponentFactory: TokenActionsComponent.Factory, + private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory, +) : AppComponentContext by context, AddToPortfolioComponent { + + private val model: AddToPortfolioModel = getOrCreateModel(params) + + private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create( + context = child("portfolioSelectorComponent"), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = model.portfolioFetcher, + controller = model.portfolioSelectorController, + ), + ) + + private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( + context = child("addTokenComponent"), + params = AddTokenComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + selectedPortfolio = model.selectedPortfolio, + selectedNetwork = model.selectedNetwork, + ), + ) + + private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create( + context = child("tokenActionsComponent"), + params = TokenActionsComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + data = model.tokenActionsData, + ), + ) + + private val childStack = childStack( + key = "addToPortfolioStack", + handleBackButton = true, + source = model.navigation, + serializer = AddToPortfolioRoutes.serializer(), + initialStack = { model.currentStack }, + childFactory = ::contentChild, + ) + + private fun onBack() { + if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss() + } + + override fun dismiss() { + params.callback.onDismiss() + } + + @Composable + override fun BottomSheet() { + val stack by childStack.subscribeAsState() + val contentStack = remember { mutableStateOf(stack) } + val currentRoute = stack.active.configuration + val isNotEmpty = currentRoute != AddToPortfolioRoutes.Empty + if (isNotEmpty) { + contentStack.value = stack + } + + TangemModalBottomSheet( + scrollableContent = false, + onBack = ::onBack, + config = TangemBottomSheetConfig( + isShown = isNotEmpty, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + title = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + BottomSheetTitle( + stack = stack, + onBackClick = ::onBack, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + content = { state -> + AnimatedContent(targetState = contentStack.value) { stack -> + val paddingModifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ) + val isScrollableContent = when (stack.active.configuration) { + AddToPortfolioRoutes.PortfolioSelector -> false + AddToPortfolioRoutes.AddToken, + AddToPortfolioRoutes.Empty, + is AddToPortfolioRoutes.NetworkSelector, + AddToPortfolioRoutes.TokenActions, + -> true + } + if (isScrollableContent) { + Column( + modifier = paddingModifier.verticalScroll(rememberScrollState()), + ) { + stack.active.instance.Content(modifier = Modifier) + } + } else { + stack.active.instance.Content(modifier = paddingModifier) + } + } + }, + ) + } + + @Composable + private fun BottomSheetTitle( + stack: ChildStack, + onBackClick: (() -> Unit), + modifier: Modifier = Modifier, + ) { + val title: TextReference = when (stack.active.configuration) { + AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token) + AddToPortfolioRoutes.Empty -> TextReference.EMPTY + is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network) + AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token) + AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent) + .title.collectAsStateWithLifecycle().value + } + val startIconRes: Int? + val endIconRes: Int? + if (stack.backStack.isNotEmpty()) { + startIconRes = R.drawable.ic_back_24 + endIconRes = null + } else { + startIconRes = null + endIconRes = R.drawable.ic_close_24 + } + TangemModalBottomSheetTitle( + modifier = modifier, + title = title, + startIconRes = startIconRes, + endIconRes = endIconRes, + onStartClick = onBackClick, + onEndClick = onBackClick, + ) + } + + private fun contentChild( + config: AddToPortfolioRoutes, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + AddToPortfolioRoutes.AddToken -> addTokenComponent + AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent + AddToPortfolioRoutes.TokenActions -> tokenActionsComponent + AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY + is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create( + context = childByContext(componentContext), + params = ChooseNetworkComponent.Params( + selectedPortfolio = config.selectedPortfolio, + callbacks = model, + ), + ) + } + + @AssistedFactory + interface Factory : AddToPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddToPortfolioComponent.Params, + ): DefaultAddToPortfolioComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt new file mode 100644 index 0000000000..9498c4041f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt @@ -0,0 +1,79 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl + +import androidx.compose.runtime.Composable +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.factory.ComponentFactory +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.TokenActionsContent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class TokenActionsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by context, ComposableContentComponent { + + private val model: TokenActionsModel = getOrCreateModel(params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TokenReceiveConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + val tokenActionsUM = state.value ?: return + TokenActionsContent( + modifier = modifier, + state = tokenActionsUM, + ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: TokenReceiveConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + + data class Params( + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val data: Flow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onLaterClick() + } + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): TokenActionsComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt new file mode 100644 index 0000000000..d42d22f23c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt @@ -0,0 +1,119 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.converter + +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddAccount +import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddData +import com.tangem.features.feed.components.market.details.portfolio.add.api.AvailableToAddWallet +import javax.inject.Inject + +internal class AvailableToAddDataConverter @Inject constructor( + private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) { + + suspend fun convert( + balances: Map, + availableNetworks: Set, + marketParams: TokenMarketParams, + ): AvailableToAddData { + suspend fun AccountStatus.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? { + val currencies = availableNetworks + .mapNotNull { network -> + createCryptoCurrency( + userWallet = wallet, + network = network, + marketParams = marketParams, + account = this.account, + ) + } + + if (currencies.isEmpty()) return null + + val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync(wallet.walletId, currencies) + .fold( + ifEmpty = { emptySet() }, + ifSome = { map -> + map.values.flatMapTo(hashSetOf()) { statuses -> + statuses.map { it.currency.network } + } + }, + ) + + return AvailableToAddAccount( + account = this, + availableNetworks = availableNetworks, + addedNetworks = addedNetworks, + ) + } + + suspend fun getAvailableToAddWallet( + entry: Map.Entry, + ): AvailableToAddWallet { + val (_, balance) = entry + val wallet = balance.userWallet + val filteredNetworks = wallet.filteredAvailableNetworks(availableNetworks) + val accounts = balance.accountsBalance.accountStatuses + val availableToAddAccounts: Map = accounts + .mapNotNull { accountStatus -> + val availableToAddAccount = accountStatus.getAvailableToAddAccount(wallet) ?: return@mapNotNull null + accountStatus.account.accountId to availableToAddAccount + } + .filter { (_, account) -> account.availableToAddNetworks.isNotEmpty() } + .toMap() + return AvailableToAddWallet( + userWallet = wallet, + accounts = accounts, + availableNetworks = filteredNetworks, + availableToAddAccounts = availableToAddAccounts, + ) + } + + val availableToAddWallets: Map = balances + .map { entry -> + val (walletId, _) = entry + val availableToAddWallet = getAvailableToAddWallet(entry) + walletId to availableToAddWallet + } + .filter { (_, wallet) -> wallet.availableToAddAccounts.isNotEmpty() } + .toMap() + + return AvailableToAddData( + availableToAddWallets = availableToAddWallets, + ) + } + + private fun UserWallet.filteredAvailableNetworks(networks: Set) = + filterAvailableNetworksForWalletUseCase( + userWalletId = this.walletId, + networks = networks, + ) + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + marketParams: TokenMarketParams, + account: Account, + ): CryptoCurrency? { + val derivationIndex = when (account) { + is Account.CryptoPortfolio -> account.derivationIndex + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = marketParams, + network = network, + accountIndex = derivationIndex, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt new file mode 100644 index 0000000000..f074c0d167 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.di + +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface AddToPortfolioComponentModule { + + @Binds + fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory + + @Binds + fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt new file mode 100644 index 0000000000..b60d84f099 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt @@ -0,0 +1,38 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AddToPortfolioModelModule { + + @Binds + @IntoMap + @ClassKey(AddTokenModel::class) + fun addTokenModel(model: AddTokenModel): Model + + @Binds + @IntoMap + @ClassKey(AddToPortfolioModel::class) + fun addToPortfolioModel(model: AddToPortfolioModel): Model + + @Binds + @IntoMap + @ClassKey(TokenActionsModel::class) + fun tokenActionsModel(model: TokenActionsModel): Model + + @Binds + @IntoMap + @ClassKey(ChooseNetworkModel::class) + fun chooseNetworkModel(model: ChooseNetworkModel): Model +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt new file mode 100644 index 0000000000..2a9b23cdd5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt @@ -0,0 +1,382 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.popToFirst +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.feed.components.market.details.portfolio.add.api.* +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions +import com.tangem.features.feed.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private const val TOKEN_ACTIONS_DELAY = 500L + +@ModelScoped +@Suppress("LongParameterList") +internal class AddToPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val callbackDelegate: AddToPortfolioCallbackDelegate, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, + val portfolioSelectorController: PortfolioSelectorController, +) : Model(), + ChooseNetworkComponent.Callbacks by callbackDelegate, + TokenActionsComponent.Callbacks by callbackDelegate, + AddTokenComponent.Callbacks by callbackDelegate { + + private val params = paramsContainer.require() + val navigation = StackNavigation() + var currentStack = listOf(AddToPortfolioRoutes.Empty) + + /* Flows that hold state and provide it to child models */ + val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() + val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() + val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() + + private val addToPortfolioManager = params.addToPortfolioManager + val portfolioFetcher = addToPortfolioManager.portfolioFetcher + val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = addToPortfolioManager.token, + source = addToPortfolioManager.analyticsParams?.source, + ) + + val featureData: Flow = combineFeatureData() + + init { + navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } + startAddToPortfolioFlow() + } + + private fun replayMutableSharedFlow() = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Suppress("LongMethod") + private fun startAddToPortfolioFlow() { + channelFlow { + fun finishFlow() { + params.callback.onDismiss() + channel.close() + } + + val featureDataFlow: StateFlow = featureData + .filterIsInstance() + .map { it.availableToAddData } + .distinctUntilChanged() + .stateIn(this) + val isAccountMode = portfolioSelectorController.isAccountModeSync() + + // use snapshot data, looks like we don’t need to remap at runtime + val data = featureDataFlow.value + + // you must control it via [AddToPortfolioManager.state] + if (!data.isAvailableToAdd) { + finishFlow() + return@channelFlow + } + + // init data flows, emits on user/code selection, updates state holder + val firstSelectedPortfolio = setupPortfolioFlow(data) + .onEach { selectedPortfolio.emit(it) } + val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio) + .onEach { selectedNetwork.emit(it) } + + val isSinglePortfolio = data.isSinglePortfolio + if (isSinglePortfolio) { + val accountId = data.availableToAddWallets.values.first() + .availableToAddAccounts.values.first() + .account.account.accountId + // force select a portfolio, triggers [selectedPortfolio] + portfolioSelectorController.selectAccount(accountId) + } else { + logAccountSelector(isAccountMode) + navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) + } + + val firstPartOfNavigation: Job = firstSelectedPortfolio + .onEach { portfolio -> + val isSingleAvailableNetwork = portfolio.account.isSingleNetwork + when { + // force select a network, triggers [selectedNetwork] + isSingleAvailableNetwork -> { + val singleNetwork = portfolio.account.availableToAddNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // it's important to control root screen, UI depends on it(close/arrow icon) + isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) + else -> navigation.pushNew(routeToNetworkSelector(portfolio)) + } + } + .launchIn(this) + + // main flow that combine all require data + val allRequireForAdd = combine( + flow = firstSelectedNetwork, + flow2 = firstSelectedPortfolio, + transform = { a, b -> a to b }, + ) + + // suspend until all required data is selected + allRequireForAdd.first() + // line of navigation to AddToken screen is finished; cancel the job, select a new root screen + firstPartOfNavigation.cancel() + navigation.replaceAll(AddToPortfolioRoutes.AddToken) + + var middleNavigationJob: Job? = null + // handle actions from AddToken screen + callbackDelegate.onChangeNetworkClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changeNetworkNavigationFlow() + .launchIn(this) + val route = routeToNetworkSelector(selectedPortfolio.first()) + navigation.pushNew(route) + } + .launchIn(this) + // handle actions from AddToken screen + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + setSelectedAccountToSelectorController() + middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this) + logAccountSelector(isAccountMode) + navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) + } + .launchIn(this) + + // suspend until token is added + val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() + middleNavigationJob?.cancel() + val selectedPortfolio = selectedPortfolio.first() + + messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) + + setupTokenActionsFlow(selectedPortfolio, addedToken) + .onEach { cryptoCurrencyData -> + tokenActionsData.emit(cryptoCurrencyData) + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } + .onEmpty { finishFlow() } + .launchIn(this) + + callbackDelegate.onLaterClick.receiveAsFlow().first() + finishFlow() + } + .catch { throwable -> + Timber.e(throwable) + params.callback.onDismiss() + } + .launchIn(modelScope) + } + + private fun logAccountSelector(isAccountMode: Boolean) { + if (isAccountMode) { + analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) + } + } + + private fun changeNetworkNavigationFlow(): Flow { + return setupNetworkFlow(selectedPortfolio) + .onEach { newNetwork -> + selectedNetwork.emit(newNetwork) + navigation.popToFirst() + } + } + + private fun setSelectedAccountToSelectorController() { + modelScope.launch(dispatchers.default) { + val selectedAccount = selectedPortfolio + .first() + .account + .account + .account + .accountId + portfolioSelectorController.selectAccount(selectedAccount) + } + } + + private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow { + val changedPortfolio = setupPortfolioFlow(data) + .drop(1) + .onEach { portfolio -> navigation.pushNew(routeToNetworkSelector(portfolio)) } + val changedNetwork = setupNetworkFlow(changedPortfolio) + return combine( + flow = changedPortfolio, + flow2 = changedNetwork, + transform = { newPortfolio, newNetwork -> + selectedPortfolio.tryEmit(newPortfolio) + selectedNetwork.tryEmit(newNetwork) + navigation.popToFirst() + }, + ) + } + + private fun setupTokenActionsFlow( + selectedPortfolio: SelectedPortfolio, + addedToken: CryptoCurrencyStatus, + ): Flow { + val timeFlow = channelFlow { + val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } + getCryptoCurrencyActionsUseCase( + currency = addedToken.currency, + accountId = selectedPortfolio.account.account.account.accountId, + ).onEach { state -> + val requestedQuickActions = toQuickActions(state.states) + when { + requestedQuickActions.isNotEmpty() -> { + timerJob.cancel() + send(state) + } + // wait any requestedQuickActions while timer active + timerJob.isActive -> Unit + else -> close() + } + }.collect() + } + return timeFlow.map { actionsState -> + PortfolioData.CryptoCurrencyData( + userWallet = selectedPortfolio.userWallet, + status = actionsState.cryptoCurrencyStatus, + actions = actionsState.states, + ) + } + } + + private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( + flow = portfolioSelectorController.isAccountMode, + flow2 = portfolioSelectorController.selectedAccount, + transform = { isAccountMode, selectedAccountId -> + selectedAccountId ?: return@combine null + val availableToAddWallets = + data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null + val availableToAddAccount = + availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null + if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) + SelectedPortfolio( + isAccountMode = isAccountMode, + userWallet = availableToAddWallets.userWallet, + account = availableToAddAccount, + isAvailableMorePortfolio = !data.isSinglePortfolio, + ) + }, + ) + .filterNotNull() + + private fun setupNetworkFlow(selectedPortfolioFlow: Flow): Flow = combine( + flow = selectedPortfolioFlow, + flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(), + transform = transform@{ selectedPortfolio, selectedNetwork -> + SelectedNetwork( + cryptoCurrency = createCryptoCurrency( + userWallet = selectedPortfolio.userWallet, + network = selectedNetwork, + account = selectedPortfolio.account, + ) ?: return@transform null, + selectedNetwork = selectedNetwork, + isAvailableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + ) + }, + ) + .filterNotNull() + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + account: AvailableToAddAccount, + ): CryptoCurrency? { + val accountIndex = when (account.account) { + is AccountStatus.CryptoPortfolio -> account.account.account.derivationIndex + } + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = addToPortfolioManager.token, + network = network, + accountIndex = accountIndex, + ) + } + + private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector { + return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio) + } + + private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> + when (state) { + is AddToPortfolioManager.State.AvailableToAdd -> + portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> + val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] + ?: return@isEnabled false + val availableAccount = + availableWallet.availableToAddAccounts[accountStatus.account.accountId] + return@isEnabled availableAccount != null + } + AddToPortfolioManager.State.Init, + AddToPortfolioManager.State.NothingToAdd, + -> Unit + } + } +} + +@ModelScoped +internal class AddToPortfolioCallbackDelegate @Inject constructor() : + ChooseNetworkComponent.Callbacks, + TokenActionsComponent.Callbacks, + AddTokenComponent.Callbacks { + + val onNetworkSelected = Channel() + val onLaterClick = Channel() + val onChangeNetworkClick = Channel() + val onChangePortfolioClick = Channel() + val onTokenAdded = Channel() + + override fun onNetworkSelected(network: TokenMarketInfo.Network) { + onNetworkSelected.trySend(network) + } + + override fun onLaterClick() { + onLaterClick.trySend(Unit) + } + + override fun onChangeNetworkClick() { + onChangeNetworkClick.trySend(Unit) + } + + override fun onChangePortfolioClick() { + onChangePortfolioClick.trySend(Unit) + } + + override fun onTokenAdded(status: CryptoCurrencyStatus) { + onTokenAdded.trySend(status) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt new file mode 100644 index 0000000000..3d954d2239 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt @@ -0,0 +1,28 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.navigation.Route +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +internal sealed interface AddToPortfolioRoutes : Route { + + @Serializable + data object Empty : AddToPortfolioRoutes + + @Serializable + data object PortfolioSelector : AddToPortfolioRoutes + + @Serializable + data class NetworkSelector( + val selectedPortfolio: SelectedPortfolio, + ) : AddToPortfolioRoutes + + @Serializable + data object AddToken : AddToPortfolioRoutes + + @Serializable + data object TokenActions : AddToPortfolioRoutes +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt new file mode 100644 index 0000000000..830e71e6de --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt @@ -0,0 +1,118 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.models.account.Account +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress +import com.tangem.features.feed.impl.R +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class AddTokenModel @Inject constructor( + paramsContainer: ParamsContainer, + private val uiBuilder: AddTokenUiBuilder, + private val messageSender: UiMessageSender, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, +) : Model() { + + private val params = paramsContainer.require() + private val analyticsEventBuilder = params.eventBuilder + private val addTokenJob = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow(value = null) + + init { + combine( + flow = params.selectedNetwork.distinctUntilChanged(), + flow2 = params.selectedPortfolio.distinctUntilChanged(), + transform = { selectedNetwork, selectedPortfolio -> + addTokenJob.cancel() + val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio) + uiBuilder.updateContent( + selectedPortfolio = selectedPortfolio, + selectedNetwork = selectedNetwork, + isTangemIconVisible = isTangemIconVisible, + onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) }, + ) + }, + ) + .onEach { newUI -> uiState.value = newUI } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onAddClick(selectedNetwork: SelectedNetwork, selectedPortfolio: SelectedPortfolio) = + modelScope.launch(dispatchers.default) { + val um = uiState.value ?: return@launch + uiState.value = um.toggleProgress(true) + val blockchainNames = listOf(selectedNetwork.selectedNetwork) + .mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name } + analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) + + val cryptoCurrency = selectedNetwork.cryptoCurrency + val account = selectedPortfolio.account.account.account + val accountId = account.accountId + manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) + .onLeft { throwable -> + processError(error = throwable) + uiState.value = um.toggleProgress(false) + return@launch + } + + val status = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = accountId.userWalletId, + currencyId = cryptoCurrency.id, + network = cryptoCurrency.network, + ).getOrNull() + if (status == null) { + processError(error = null) + } else { + when (account) { + is Account.CryptoPortfolio -> if (!account.isMainAccount) { + analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount()) + } + } + params.callbacks.onTokenAdded(status.status) + } + uiState.value = um.toggleProgress(false) + } + + private suspend fun needColdWalletInteraction( + selectedNetwork: SelectedNetwork, + selectedPortfolio: SelectedPortfolio, + ): Boolean = coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = selectedPortfolio.userWallet.walletId, + networksWithDerivationPath = mapOf(selectedNetwork.selectedNetwork.networkId to null), + ) + + private fun processError(error: Throwable?) { + val message = error?.message?.let { stringReference(it) } + ?: resourceReference(R.string.common_something_went_wrong) + messageSender.send(ToastMessage(message = message)) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt new file mode 100644 index 0000000000..24b4079240 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt @@ -0,0 +1,105 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.common.ui.account.CryptoPortfolioIconUM +import com.tangem.common.ui.account.PortfolioSelectUM +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.addtoken.AddTokenUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.iconResId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.AccountStatus +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedNetwork +import com.tangem.features.feed.components.market.details.portfolio.add.api.SelectedPortfolio +import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent +import com.tangem.features.feed.impl.R +import javax.inject.Inject + +@ModelScoped +internal class AddTokenUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, +) { + private val params = paramsContainer.require() + + private fun createNetwork(selectedNetwork: SelectedNetwork): AddTokenUM.Network { + return AddTokenUM.Network( + icon = selectedNetwork.cryptoCurrency.network.iconResId, + name = stringReference(selectedNetwork.cryptoCurrency.network.name), + editable = selectedNetwork.isAvailableMoreNetwork, + onClick = { params.callbacks.onChangeNetworkClick() }, + ) + } + + private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM { + val accountIcon: CryptoPortfolioIconUM? + val portfolioName: TextReference + when (selectedPortfolio.isAccountMode) { + false -> { + accountIcon = null + portfolioName = stringReference(selectedPortfolio.userWallet.name) + } + true -> { + val accountStatus = selectedPortfolio.account.account + portfolioName = accountStatus.account.accountName.toUM().value + accountIcon = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.account.icon.toUM() + } + } + } + return PortfolioSelectUM( + icon = accountIcon, + name = portfolioName, + isAccountMode = selectedPortfolio.isAccountMode, + isMultiChoice = selectedPortfolio.isAvailableMorePortfolio, + onClick = { params.callbacks.onChangePortfolioClick() }, + ) + } + + fun updateContent( + selectedPortfolio: SelectedPortfolio, + selectedNetwork: SelectedNetwork, + isTangemIconVisible: Boolean, + onConfirmClick: () -> Unit, + ): AddTokenUM { + // its may happens when change portfolio after selected both params in line navigation + val isAvailableNetwork = selectedPortfolio.account.availableToAddNetworks + .any { selectedNetwork.selectedNetwork.networkId == it.networkId } + val button = AddTokenUM.Button( + isEnabled = isAvailableNetwork, + showProgress = false, + isTangemIconVisible = isTangemIconVisible, + text = resourceReference(R.string.common_add), + onConfirmClick = onConfirmClick, + ) + val networkUM = createNetwork(selectedNetwork) + val portfolioUM = createPortfolio(selectedPortfolio) + val currency = selectedNetwork.cryptoCurrency + val tokenToAdd = TokenItemState.Content( + id = currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(currency), + titleState = TokenItemState.TitleState.Content(stringReference(currency.name)), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = ""), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = ""), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return AddTokenUM( + tokenToAdd = tokenToAdd, + network = networkUM, + portfolio = portfolioUM, + button = button, + ) + } + + companion object { + + fun AddTokenUM.toggleProgress(showProgress: Boolean) = this.copy( + button = this.button.copy(showProgress = showProgress), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt new file mode 100644 index 0000000000..4d51ae620c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt @@ -0,0 +1,126 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.feed.components.market.details.portfolio.impl.model.BlockchainRowUMConverter +import com.tangem.features.feed.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class ChooseNetworkModel @Inject constructor( + paramsContainer: ParamsContainer, + private val messageSender: UiMessageSender, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow = MutableStateFlow(buildUI()) + + private fun buildUI(): ChooseNetworkUM { + val allAvailable = params.selectedPortfolio.account.availableNetworks + val alreadyAdded = allAvailable + .subtract(params.selectedPortfolio.account.availableToAddNetworks) + val converter = BlockchainRowUMConverter( + alreadyAddedNetworks = alreadyAdded.mapTo(mutableSetOf()) { it.networkId }, + ) + val allAvailableNetworks = allAvailable.map { it to true } + return ChooseNetworkUM( + networks = converter.convertList(allAvailableNetworks).toPersistentList(), + onNetworkClick = onNetworkClick@{ row -> + val network = allAvailable + .find { it.networkId == row.id } + ?: return@onNetworkClick + checkNetwork(row, network) + }, + ) + } + + private fun checkNetwork(row: BlockchainRowUM, network: TokenMarketInfo.Network) = modelScope.launch { + val selectedWalletId = params.selectedPortfolio.userWallet.walletId + val unsupportedState = checkCurrencyUnsupportedState( + userWalletId = selectedWalletId, + rawNetworkId = row.id, + isMainNetwork = row.isMainNetwork, + ) + if (unsupportedState != null) { + showUnsupportedWarning(unsupportedState) + } else { + params.callbacks.onNetworkSelected(network) + } + } + + private suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? { + return checkCurrencyUnsupportedUseCase( + userWalletId = userWalletId, + networkId = rawNetworkId, + isMainNetwork = isMainNetwork, + ).getOrElse { throwable -> + Timber.e( + throwable, + """ + Failed to check currency unsupported state + |- User wallet ID: $userWalletId + |- Network ID: $rawNetworkId + |- Is main network: $isMainNetwork + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = throwable.localizedMessage?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + null + } + } + + private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) { + val message = DialogMessage( + message = when (unsupportedState) { + is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + }, + ) + + messageSender.send(message) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt new file mode 100644 index 0000000000..0bb9a30fd9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt @@ -0,0 +1,76 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +@Suppress("LongParameterList") +internal class TokenActionsModel @Inject constructor( + paramsContainer: ParamsContainer, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + tokenActionsIntentsFactory: TokenActionsHandler.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val uiBuilder: TokenActionsUiBuilder, + private val analyticsEventHandler: AnalyticsEventHandler, + private val receiveAddressesFactory: ReceiveAddressesFactory, +) : Model() { + + private val params = paramsContainer.require() + private val analyticsEventBuilder get() = params.eventBuilder + private val currentAppCurrency = getSelectedAppCurrencyUseCase.invokeOrDefault() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val tokenActionsHandler: TokenActionsHandler = + tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { }, + onHandleQuickAction = { handledAction -> handledQuickAction(handledAction) }, + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val uiState: StateFlow = params.data + .mapLatest { uiBuilder.build(it, tokenActionsHandler) } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) { + val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action) + analyticsEventHandler.send(event) + val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive + if (!isReceive) return + modelScope.launch { + val tokenConfig = receiveAddressesFactory.create( + status = handledAction.cryptoCurrencyData.status, + userWalletId = handledAction.cryptoCurrencyData.userWallet.walletId, + ) ?: return@launch + bottomSheetNavigation.activate(tokenConfig) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt new file mode 100644 index 0000000000..8828010452 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt @@ -0,0 +1,44 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.model + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter +import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler +import javax.inject.Inject + +@ModelScoped +internal class TokenActionsUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, + private val analyticsEventHandler: AnalyticsEventHandler, +) { + private val params = paramsContainer.require() + + fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { + val status = data.status + val tokenUM = TokenItemState.Content( + id = status.currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), + titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return TokenActionsUM( + token = tokenUM, + onLaterClick = { + analyticsEventHandler.send(params.eventBuilder.getTokenLater()) + params.callbacks.onLaterClick() + }, + quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt new file mode 100644 index 0000000000..7b92e78e3b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt @@ -0,0 +1,110 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +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.util.fastForEachIndexed +import com.tangem.core.ui.components.label.Label +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.rows.BlockchainRow +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +private const val DISABLED_ALPHA = 0.4f + +@Composable +internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.networks.fastForEachIndexed { index, model -> + key(model.id) { + BlockchainRow( + model = model, + itemPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing14, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }), + ) { + if (!model.isEnabled) { + Label( + modifier = Modifier.alpha(DISABLED_ALPHA), + state = LabelUM( + text = resourceReference(R.string.common_added), + style = LabelStyle.REGULAR, + ), + ) + } + } + } + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { + TangemThemePreview { + ChooseNetworkContent( + state = content, + ) + } +} + +internal class ChooseNetworkContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = UUID.randomUUID().toString(), + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.img_eth_22, + isMainNetwork = false, + isSelected = true, + isEnabled = true, + ) + + override val values: Sequence + get() = sequenceOf( + ChooseNetworkUM( + onNetworkClick = {}, + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + ), + blockchainRow.copy( + iconResId = R.drawable.ic_bsc_16, + isEnabled = false, + ), + blockchainRow.copy(iconResId = R.drawable.img_polygon_22), + blockchainRow.copy(iconResId = R.drawable.img_optimism_22), + ), + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt new file mode 100644 index 0000000000..f286f46e40 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt @@ -0,0 +1,72 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class DefaultAddToPortfolioManager @AssistedInject constructor( + private val availableToAddDataConverter: AvailableToAddDataConverter, + @Assisted override val token: TokenMarketParams, + @Assisted override val analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + @Assisted val scope: CoroutineScope, + dispatchers: CoroutineDispatcherProvider, + portfolioFetcherFactory: PortfolioFetcher.Factory, +) : AddToPortfolioManager { + + private val _allAvailableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val allAvailableNetworks: Flow> = _allAvailableNetworks.asSharedFlow() + override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), + scope = scope, + ) + + override val state: StateFlow = + combine( + flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), + flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(), + ) { balances, availableNetworks -> + val data = availableToAddDataConverter.convert( + balances = balances, + availableNetworks = availableNetworks, + marketParams = token, + ) + if (data.isAvailableToAdd) { + AddToPortfolioManager.State.AvailableToAdd(data) + } else { + AddToPortfolioManager.State.NothingToAdd + } + } + .flowOn(dispatchers.default) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = AddToPortfolioManager.State.Init, + ) + + override fun setTokenNetworks(networks: List) { + _allAvailableNetworks.tryEmit(networks) + } + + @AssistedFactory + interface Factory : AddToPortfolioManager.Factory { + override fun create( + scope: CoroutineScope, + token: TokenMarketParams, + analyticsParams: MarketsPortfolioComponent.AnalyticsParams?, + ): DefaultAddToPortfolioManager + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt new file mode 100644 index 0000000000..d88388bb30 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt @@ -0,0 +1,204 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +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 androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.icons.badge.drawBadge +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +@Composable +internal fun TokenActionsContent(state: TokenActionsUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + TokenItem( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action), + state = state.token, + isBalanceHidden = false, + ) + + SpacerH(TangemTheme.dimens.spacing14) + Column( + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.quickActions.actions.fastForEach { actionUM -> + key(actionUM.title) { + ActionRow( + state = actionUM, + onClick = { state.quickActions.onQuickActionClick(actionUM) }, + onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }, + ) + } + } + } + + SpacerH16() + + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_later), + onClick = state.onLaterClick, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ActionRow( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit), + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal = { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable }, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .padding(horizontal = TangemTheme.dimens.spacing12, vertical = TangemTheme.dimens.spacing15), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + val containerColor = TangemTheme.colors.background.action + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = CircleShape, + ) + .size(36.dp) + .drawWithContent { + drawContent() + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + drawBadge(containerColor = containerColor, offset = 4.dp) + } + }, + ) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size16), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(TokenActionsContentPreviewProvider::class) state: TokenActionsUM) { + TangemThemePreview { + TokenActionsContent( + state = state, + ) + } +} + +private class TokenActionsContentPreviewProvider : PreviewParameterProvider { + private val tokenState + get() = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content( + text = stringReference(value = "Tether"), + ), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), + onItemClick = {}, + onItemLongClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + TokenActionsUM( + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + token = tokenState, + onLaterClick = {}, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt new file mode 100644 index 0000000000..8f54e3cb3b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +data class ChooseNetworkUM( + val networks: ImmutableList, + val onNetworkClick: (BlockchainRowUM) -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt new file mode 100644 index 0000000000..d90e3ca130 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state + +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM + +internal data class TokenActionsUM( + val token: TokenItemState, + val quickActions: PortfolioTokenUM.QuickActions, + val onLaterClick: () -> Unit, +) \ No newline at end of file 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 new file mode 100644 index 0000000000..18aa2ea032 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/api/MarketsPortfolioComponent.kt @@ -0,0 +1,29 @@ +package com.tangem.features.feed.components.market.details.portfolio.api + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import kotlinx.serialization.Serializable + +@Stable +interface MarketsPortfolioComponent : ComposableContentComponent { + + @Serializable + data class Params( + val token: TokenMarketParams, + val analyticsParams: AnalyticsParams?, + ) + + @Serializable + data class AnalyticsParams( + val source: String, + ) + + fun setTokenNetworks(networks: List) + + fun setNoNetworksAvailable() + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt new file mode 100644 index 0000000000..cd1eb2d9ee --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -0,0 +1,89 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl + +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.decompose.ComposableBottomSheetComponent +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.MyPortfolio +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: MarketsPortfolioComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, +) : AppComponentContext by context, MarketsPortfolioComponent { + + private val model: MarketsPortfolioModel = getOrCreateModel(params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = MarketsPortfolioRoute.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + override fun setTokenNetworks(networks: List) { + model.setTokenNetworks(networks) + } + + override fun setNoNetworksAvailable() { + model.setNoNetworksAvailable() + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + MyPortfolio(modifier = modifier, state = state) + bottomSheet.child?.instance?.BottomSheet() + } + + @Suppress("UnsafeCallOnNullableType") + private fun bottomSheetChild( + config: MarketsPortfolioRoute, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + MarketsPortfolioRoute.AddToPortfolio -> addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = model.newAddToPortfolioManager!!, + callback = model.addToPortfolioCallback, + ), + ) + is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create( + context = childByContext(componentContext), + params = TokenReceiveComponent.Params( + config = config.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + } + + @AssistedFactory + interface Factory : MarketsPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: MarketsPortfolioComponent.Params, + ): DefaultMarketsPortfolioComponent + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt new file mode 100644 index 0000000000..ee93e97a0c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -0,0 +1,74 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM + +internal class PortfolioAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + + data class EventBuilder( + val token: TokenMarketParams, + val source: String?, + ) { + + fun addToPortfolioClicked() = PortfolioAnalyticsEvent( + event = "Button - Add To Portfolio", + params = mapOf( + "Token" to token.symbol, + ), + ) + + fun popupToChooseAccount() = PortfolioAnalyticsEvent( + event = "Popup to choose account", + ) + + fun addToNotMainAccount() = PortfolioAnalyticsEvent( + event = "Button - Add (token not to main Account)", + ) + + fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent(event = "Wallet Selected") + + fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( + event = "Token Network Selected", + params = mapOf( + "Count" to blockchainNames.size.toString(), + "Token" to token.symbol, + "blockchain" to blockchainNames.joinToString(separator = ", "), + ), + ) + + fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) = + PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Button - Buy" + TokenActionsBSContentUM.Action.Receive -> "Button - Receive" + TokenActionsBSContentUM.Action.Exchange -> "Button - Swap" + TokenActionsBSContentUM.Action.Stake -> "Button - Stake" + TokenActionsBSContentUM.Action.YieldMode -> "Button - Yield Mode" + else -> "error" + }, + params = buildMap { + put("Token", token.symbol) + source?.let { put("Source", it) } + put("blockchain", blockchainName) + }, + ) + + fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy" + TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive" + TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange" + TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake" + else -> "error" + }, + ) + + fun getTokenLater() = PortfolioAnalyticsEvent( + event = "Popup Get token - Button Later", + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..beb881a924 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.di + +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.DefaultMarketsPortfolioComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindMarketsPortfolioComponent( + factory: DefaultMarketsPortfolioComponent.Factory, + ): MarketsPortfolioComponent.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt new file mode 100644 index 0000000000..4656963d6b --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(MarketsPortfolioModel::class) + fun provideMarketsPortfolioModel(model: MarketsPortfolioModel): Model +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt new file mode 100644 index 0000000000..044a0ef229 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt @@ -0,0 +1,33 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.loader + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.TokenActionsState + +/** + * Portfolio data. Combined data from all flows that required to setup portfolio + * + * @property walletsWithCurrencies wallets with crypto currency statuses + * @property appCurrency app currency + * @property isBalanceHidden flag that indicates if balance should be hidden + * @property walletsWithBalance wallets with total balance + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioData( + val walletsWithCurrencies: Map>, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val walletsWithBalance: Map>, +) { + data class CryptoCurrencyData( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val actions: List, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt new file mode 100644 index 0000000000..c22e143ea1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioDataLoader.kt @@ -0,0 +1,135 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.loader + +import arrow.core.getOrElse +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetAllWalletsCryptoCurrencyStatusesUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.yield.supply.models.YieldSupplyAvailability +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +/** + * Loader of portfolio data + * + * @property getAllWalletsCryptoCurrencyStatusesUseCase use case for getting all wallets crypto currency statuses + * @property getSelectedAppCurrencyUseCase use case for getting selected app currency + * @property getBalanceHidingSettingsUseCase use case for getting balance hiding settings + * @property getWalletTotalBalanceUseCase use case for getting wallet total balance + * +[REDACTED_AUTHOR] + */ +internal class PortfolioDataLoader @Inject constructor( + private val getAllWalletsCryptoCurrencyStatusesUseCase: GetAllWalletsCryptoCurrencyStatusesUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase, + private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, +) { + + /** Load data by [currencyRawId] */ + @OptIn(ExperimentalCoroutinesApi::class) + fun load(currencyRawId: CryptoCurrency.RawID): Flow { + return combine( + flow = getAllWalletsCryptoCurrenciesData(currencyRawId = currencyRawId), + flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + ) { walletsWithCurrencies, appCurrency, isBalanceHidden -> + PortfolioData( + walletsWithCurrencies = walletsWithCurrencies, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + walletsWithBalance = emptyMap(), + ) + } + // setup balances for wallets from walletsWithCurrencyStatuses + .flatMapLatest { portfolioData -> + getWalletsWithTotalBalanceFlow( + ids = portfolioData.walletsWithCurrencies.keys.map(UserWallet::walletId), + ) + .map { portfolioData.copy(walletsWithBalance = it) } + .onEmpty { emit(portfolioData) } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getAllWalletsCryptoCurrenciesData( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + return getAllWalletsCryptoCurrencyStatusesUseCase(currencyRawId) + .distinctUntilChanged() + .map { walletsWithMaybeStatuses -> + walletsWithMaybeStatuses.mapValues { entry -> + entry.value.mapNotNull { it.getOrNull() } + } + } + .flatMapLatest { walletsWithStatuses -> + val actionsFlows = walletsWithStatuses.flatMap { (wallet, statuses) -> + statuses.map { status -> + val yieldSupplyAvailability = yieldSupplyGetAvailabilityUseCase(status.currency).getOrElse { + YieldSupplyAvailability.Unavailable + } + getCryptoCurrencyActionsUseCase(wallet, status, yieldSupplyAvailability) + .map { tokenActionsState -> + PortfolioData.CryptoCurrencyData( + userWallet = wallet, + status = status, + actions = tokenActionsState.states, + ) + } + } + } + + combine(actionsFlows) { actions -> + walletsWithStatuses.mapValues { entry -> + entry.value.mapNotNull { status -> + actions.firstOrNull { + it.userWallet == entry.key && it.status == status + } + } + } + }.onEmpty { + emit( + walletsWithStatuses.mapValues { (wallet, statuses) -> + statuses.map { status -> + PortfolioData.CryptoCurrencyData( + userWallet = wallet, + status = status, + actions = emptyList(), + ) + } + }, + ) + } + }.onEmpty { + emit(emptyMap()) + } + .distinctUntilChanged() + } + + private fun getWalletsWithTotalBalanceFlow( + ids: List, + ): Flow>> { + return combine( + flows = ids + .map { userWalletId -> + getWalletTotalBalanceUseCase(userWalletId) + .map { userWalletId to it } + .distinctUntilChanged() + }, + transform = { it.toMap() }, + ) + .distinctUntilChanged() + .onEmpty { ids.associateWith { Lce.Loading(partialContent = null) } } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt new file mode 100644 index 0000000000..e139659522 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioBSContentUMFactory.kt @@ -0,0 +1,161 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM +import kotlinx.collections.immutable.toImmutableList + +/** + * Factory to create AddToPortfolio bottom sheet content [TangemBottomSheetConfig] + * + * @property token token params + * @property onAddToPortfolioVisibilityChange callback is invoked when add to portfolio visibility is changed + * @property onWalletSelectorVisibilityChange callback is invoked when wallet selector visibility is changed + * @property onNetworkSwitchClick callback is invoked when network switch is clicked + * @property onAnotherWalletSelect callback is invoked when wallet is selected + * @property onContinueClick callback is invoked when continue button is clicked + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +internal class AddToPortfolioBSContentUMFactory( + private val addToPortfolioManager: AddToPortfolioManager, + private val token: TokenMarketParams, + private val onAddToPortfolioVisibilityChange: (Boolean) -> Unit, + private val onWalletSelectorVisibilityChange: (Boolean) -> Unit, + private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, + private val onAnotherWalletSelect: (UserWalletId) -> Unit, + private val onContinueClick: (selectedWalletId: UserWalletId, addedNetworks: Set) -> Unit, +) { + + /** + * Create [TangemBottomSheetConfig] + * + + * @param portfolioData portfolio data + * @param portfolioUIData portfolio bottom sheet visibility model + * @param selectedWallet selected wallet + * @param alreadyAddedNetworks already added networks + */ + @Suppress("LongParameterList") + fun create( + currentState: TangemBottomSheetConfig?, + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + selectedWallet: UserWallet?, + alreadyAddedNetworks: Set?, + artworks: Map, + ): TangemBottomSheetConfig { + return (currentState ?: TangemBottomSheetConfig.Empty).copy( + isShown = portfolioUIData.portfolioBSVisibilityModel.isAddToPortfolioBSVisible, + onDismissRequest = { onAddToPortfolioVisibilityChange(false) }, + content = if (selectedWallet != null && alreadyAddedNetworks != null) { + AddToPortfolioBSContentUM( + selectedWallet = selectedWallet.toSelectedUserWalletItemUM( + portfolioData = portfolioData, + balance = portfolioData.walletsWithBalance[selectedWallet.walletId]?.getOrNull(), + artwork = artworks[selectedWallet.walletId], + ), + selectNetworkUM = SelectNetworkUMConverter( + networksWithToggle = addToPortfolioManager.associateWithToggle( + userWalletId = selectedWallet.walletId, + alreadyAddedNetworkIds = alreadyAddedNetworks, + addToPortfolioData = portfolioUIData.addToPortfolioData, + ), + alreadyAddedNetworks = alreadyAddedNetworks, + onNetworkSwitchClick = onNetworkSwitchClick, + ).convert(value = token), + isScanCardNotificationVisible = portfolioUIData.isNeededColdWalletInteraction, + isContinueButtonEnabled = portfolioUIData.addToPortfolioData.isUserAddedNetworks( + userWalletId = selectedWallet.walletId, + ), + onContinueButtonClick = { + onContinueClick( + selectedWallet.walletId, + portfolioUIData.addToPortfolioData.getAddedNetworks( + userWalletId = selectedWallet.walletId, + alreadyAddedNetworkIds = alreadyAddedNetworks, + ), + ) + }, + walletSelectorConfig = createWalletSelectorBSConfig( + isShow = portfolioUIData.portfolioBSVisibilityModel.isWalletSelectorBSVisible, + portfolioData = portfolioData, + selectedWalletId = selectedWallet.walletId, + artworks = artworks, + ), + isWalletBlockVisible = portfolioData.walletsWithCurrencies + .filterKeys(UserWallet::isMultiCurrency).size > 1, + ) + } else { + TangemBottomSheetConfigContent.Empty + }, + ) + } + + private fun UserWallet.toSelectedUserWalletItemUM( + artwork: UserWalletItemUM.ImageState? = null, + portfolioData: PortfolioData, + balance: TotalFiatBalance?, + ): UserWalletItemUM { + return UserWalletItemUMConverter( + onClick = { onWalletSelectorVisibilityChange(true) }, + endIcon = UserWalletItemUM.EndIcon.Arrow, + balance = balance, + artwork = artwork, + appCurrency = portfolioData.appCurrency, + isBalanceHidden = portfolioData.isBalanceHidden, + ).convert(value = this) + } + + private fun createWalletSelectorBSConfig( + isShow: Boolean, + portfolioData: PortfolioData, + selectedWalletId: UserWalletId, + artworks: Map, + ): TangemBottomSheetConfig { + return TangemBottomSheetConfig( + isShown = isShow, + onDismissRequest = { onWalletSelectorVisibilityChange(false) }, + content = WalletSelectorBSContentUM( + userWallets = portfolioData.walletsWithCurrencies + .filterKeys(UserWallet::isMultiCurrency) + .map { it.key } + .map { userWallet -> + val balance = portfolioData.walletsWithBalance[userWallet.walletId] + + UserWalletItemUMConverter( + onClick = { id -> + if (id != selectedWalletId) { + onAnotherWalletSelect(id) + onWalletSelectorVisibilityChange(false) + } + }, + appCurrency = portfolioData.appCurrency, + balance = balance?.getOrNull(), + isBalanceHidden = portfolioData.isBalanceHidden, + endIcon = if (userWallet.walletId == selectedWalletId) { + UserWalletItemUM.EndIcon.Checkmark + } else { + UserWalletItemUM.EndIcon.None + }, + artwork = artworks[userWallet.walletId], + ).convert(userWallet) + } + .toImmutableList(), + onBack = { onWalletSelectorVisibilityChange(false) }, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt new file mode 100644 index 0000000000..d176536657 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/AddToPortfolioManager.kt @@ -0,0 +1,193 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import timber.log.Timber +import javax.inject.Inject +import kotlin.collections.firstOrNull +import kotlin.collections.orEmpty +import kotlin.collections.set + +internal typealias WalletsWithNetworks = Map> + +/** + * Manager for tracking changing networks in AddToPortfolio + * +[REDACTED_AUTHOR] + */ +internal class AddToPortfolioManager @Inject constructor( + private val filterAvailableNetworksForWalletUseCase: FilterAvailableNetworksForWalletUseCase, +) { + + val availableNetworks = MutableStateFlow?>(value = null) + private val addedNetworks = MutableStateFlow(value = emptyMap()) + private val removedNetworks = MutableStateFlow(value = emptyMap()) + + /** Get [AddToPortfolioData] as flow */ + fun getAddToPortfolioData(): Flow { + return combine( + flow = availableNetworks, + flow2 = addedNetworks, + flow3 = removedNetworks, + transform = ::AddToPortfolioData, + ) + } + + /** Set available networks [networks] */ + fun setAvailableNetworks(networks: List) { + availableNetworks.value = networks.toSet() + } + + /** Add network [networkId] to [userWalletId] */ + fun addNetwork(userWalletId: UserWalletId, networkId: String) { + addedNetworks.add(userWalletId, networkId) + + removedNetworks.cancelPrevChangeIfExist(userWalletId = userWalletId, networkId = networkId) + } + + /** Remove network [networkId] from [userWalletId] */ + fun removeNetwork(userWalletId: UserWalletId, networkId: String) { + removedNetworks.add(userWalletId, networkId) + + addedNetworks.cancelPrevChangeIfExist( + userWalletId = userWalletId, + networkId = networkId, + ) + } + + /** Remove all networks by [userWalletId] */ + fun removeAllChanges(userWalletId: UserWalletId) { + addedNetworks.update { + it.toMutableMap().apply { remove(userWalletId) } + } + + removedNetworks.update { + it.toMutableMap().apply { remove(userWalletId) } + } + } + + fun associateWithToggle( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + addToPortfolioData: AddToPortfolioData, + ): Map { + val filteredNetworks = filterAvailableNetworksForWalletUseCase( + userWalletId = userWalletId, + networks = addToPortfolioData.availableNetworks.orEmpty(), + ) + // Use user choice or check already added networks + return filteredNetworks.associateWith { availableNetwork -> + val isAddedByUser = addToPortfolioData.addedNetworks[userWalletId]?.contains(availableNetwork) + + if (isAddedByUser == true) return@associateWith true + + val isRemovedByUser = addToPortfolioData.removedNetworks[userWalletId]?.contains(availableNetwork) + + if (isRemovedByUser == true) return@associateWith false + + val isAddedBefore = alreadyAddedNetworkIds.any { it == availableNetwork.networkId } + + isAddedBefore + } + } + + private fun MutableStateFlow.cancelPrevChangeIfExist( + userWalletId: UserWalletId, + networkId: String, + ) { + if (value[userWalletId].orEmpty().any { it.networkId == networkId }) remove(userWalletId, networkId) + } + + private fun MutableStateFlow.add(userWalletId: UserWalletId, networkId: String) { + change(userWalletId = userWalletId, networkId = networkId, isAddAction = true) + } + + private fun MutableStateFlow.remove(userWalletId: UserWalletId, networkId: String) { + change(userWalletId = userWalletId, networkId = networkId, isAddAction = false) + } + + private fun MutableStateFlow.change( + userWalletId: UserWalletId, + networkId: String, + isAddAction: Boolean, + ) { + val network = availableNetworks.value.orEmpty().firstOrNull { it.networkId == networkId } + + if (network == null) { + Timber.d( + "Network [$networkId] doesn't contain in available networks [%s]", + availableNetworks.value?.joinToString { it.networkId }, + ) + + return + } + + update { walletsWithNetworks -> + walletsWithNetworks.toMutableMap().apply { + this[userWalletId] = if (isAddAction) { + this[userWalletId].orEmpty() + network + } else { + this[userWalletId].orEmpty() - network + } + } + } + } + + /** + * Add to portfolio data + * + * @property availableNetworks available networks that user can add to portfolio + * @property addedNetworks networks that user toggled on, but it might have already been added to the wallet + * @property removedNetworks networks that user toggled off, but it might haven't been added to the wallet + * + * Example for [addedNetworks] and [removedNetworks]. This lists will include new networks when user just + * toggle it. But when we will save user changes, we will check what tokens have already been added or + * haven't been added to the wallet. See [getAddedNetworks] and [getRemovedNetworks] + */ + data class AddToPortfolioData( + val availableNetworks: Set?, + val addedNetworks: WalletsWithNetworks, + val removedNetworks: WalletsWithNetworks, + ) { + + fun isUserAddedNetworks(userWalletId: UserWalletId): Boolean { + return addedNetworks[userWalletId].orEmpty().isNotEmpty() + } + + fun isUserChangedNetworks(userWalletId: UserWalletId): Boolean { + return addedNetworks[userWalletId].orEmpty().isNotEmpty() || + removedNetworks[userWalletId].orEmpty().isNotEmpty() + } + + /** Get new networks that user [userWalletId] added using [alreadyAddedNetworkIds] */ + fun getAddedNetworks( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Set { + val addedNetworksByUser = addedNetworks[userWalletId].orEmpty() + + return addedNetworksByUser.map { it.networkId } + .minus(alreadyAddedNetworkIds) + .mapNotNull { networkId -> addedNetworksByUser.firstOrNull { it.networkId == networkId } } + .toSet() + } + + /** Get networks that user [userWalletId] removed using [alreadyAddedNetworkIds] */ + fun getRemovedNetworks( + userWalletId: UserWalletId, + alreadyAddedNetworkIds: Set, + ): Set { + val removedNetworksByUser = removedNetworks[userWalletId].orEmpty() + + return alreadyAddedNetworkIds + .minus(removedNetworksByUser.map { it.networkId }.toSet()) + .mapNotNull { networkId -> removedNetworksByUser.firstOrNull { it.networkId == networkId } } + .toSet() + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt new file mode 100644 index 0000000000..e4be2f3924 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt @@ -0,0 +1,64 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.getGreyedOutIconRes +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketInfo.Network +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.converter.Converter + +/** + * Converter from [TokenMarketInfo.Network] to [BlockchainRowUM] + * + * @property alreadyAddedNetworks set of already added networks + * +[REDACTED_AUTHOR] + */ +internal class BlockchainRowUMConverter( + private val alreadyAddedNetworks: Set, +) : Converter, BlockchainRowUM> { + + override fun convert(value: Pair): BlockchainRowUM { + val (network, isSelected) = value + + val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId) + ?: error("Can't find blockchain info for ${network.networkId}") + + val isMainNetwork = network.contractAddress == null + + val isEnabled = !alreadyAddedNetworks.contains(network.networkId) + + return BlockchainRowUM( + id = network.networkId, + name = blockchainInfo.name, + type = getNetworkType(network, blockchainInfo), + iconResId = if (isEnabled) { + if (isSelected) { + getActiveIconRes(blockchainInfo.blockchainId) + } else { + getGreyedOutIconRes(blockchainInfo.blockchainId) + } + } else { + getGreyedOutIconRes(blockchainInfo.blockchainId) + }, + isMainNetwork = isMainNetwork, + isSelected = isSelected, + isEnabled = isEnabled, + ) + } + + private fun getNetworkType(network: Network, blockchainInfo: BlockchainUtils.BlockchainInfo): String { + val isMainNetwork = network.contractAddress == null + return when { + BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME + isMainNetwork -> MAIN_NETWORK_TYPE_NAME + else -> blockchainInfo.protocolName + } + } + + private companion object { + const val MAIN_NETWORK_TYPE_NAME = "MAIN" + const val MAIN_NETWORK_L2_TYPE_NAME = "MAIN L2" + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt new file mode 100644 index 0000000000..550f17efbc --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt @@ -0,0 +1,440 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase +import com.tangem.domain.managetokens.model.CurrencyUnsupportedState +import com.tangem.domain.markets.SaveMarketTokensUseCase +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory +import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent +import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioDataLoader +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.feed.impl.R +import com.tangem.features.tokenreceive.TokenReceiveFeatureToggle +import com.tangem.features.wallet.utils.UserWalletImageFetcher +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.operations.attestation.ArtworkSize +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import com.tangem.features.feed.components.market.details.portfolio.add.api.AddToPortfolioManager as NewAddToPortfolioManager + +@Suppress("LongParameterList", "LargeClass") +@Stable +@ModelScoped +internal class MarketsPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + tokenActionsIntentsFactory: TokenActionsHandler.Factory, + override val dispatchers: CoroutineDispatcherProvider, + private val messageSender: UiMessageSender, + private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, + private val portfolioDataLoader: PortfolioDataLoader, + private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase, + private val saveMarketTokensUseCase: SaveMarketTokensUseCase, + private val addToPortfolioManager: AddToPortfolioManager, + private val analyticsEventHandler: AnalyticsEventHandler, + private val tokenReceiveFeatureToggle: TokenReceiveFeatureToggle, + private val userWalletImageFetcher: UserWalletImageFetcher, + private val receiveAddressesFactory: ReceiveAddressesFactory, + accountsFeatureToggles: AccountsFeatureToggles, + newAddToPortfolioManagerFactory: NewAddToPortfolioManager.Factory, + newMarketsPortfolioDelegateFactory: NewMarketsPortfolioDelegate.Factory, +) : Model() { + + private val _state: MutableStateFlow = MutableStateFlow(value = MyPortfolioUM.Loading) + val state: StateFlow get() = _state + + private val params = paramsContainer.require() + private val analyticsEventBuilder = PortfolioAnalyticsEvent.EventBuilder( + token = params.token, + source = params.analyticsParams?.source, + ) + + val newAddToPortfolioManager: NewAddToPortfolioManager? + val newMarketsPortfolioDelegate: NewMarketsPortfolioDelegate? + + /** Multi-wallet [UserWalletId] that user uses to add new tokens in AddToPortfolio bottom sheet */ + private val selectedMultiWalletIdFlow = MutableStateFlow(value = null) + + private val portfolioBSVisibilityModelFlow = MutableStateFlow(value = PortfolioBSVisibilityModel()) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { + override fun onDismiss() = bottomSheetNavigation.dismiss() + } + + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private val tokenActionsHandler = tokenActionsIntentsFactory.create( + currentAppCurrency = Provider { currentAppCurrency.value }, + updateTokenReceiveBSConfig = { updateBlock -> + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled.not()) { + updateTokensState { + it.copy(tokenReceiveBSConfig = updateBlock(it.tokenReceiveBSConfig)) + } + } + }, + onHandleQuickAction = { handledAction -> + analyticsEventHandler.send( + analyticsEventBuilder.quickActionClick( + actionUM = handledAction.action, + blockchainName = handledAction + .cryptoCurrencyData + .status + .currency + .network + .name, + ), + ) + if (tokenReceiveFeatureToggle.isNewTokenReceiveEnabled) { + configureReceiveAddresses(handledAction) + } + }, + ) + + private val factory = MyPortfolioUMFactory( + onAddClick = { + onAddToPortfolioBSVisibilityChange(isShow = true) + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioClicked(), + ) + }, + addToPortfolioBSContentUMFactory = AddToPortfolioBSContentUMFactory( + addToPortfolioManager = addToPortfolioManager, + token = params.token, + onAddToPortfolioVisibilityChange = ::onAddToPortfolioBSVisibilityChange, + onWalletSelectorVisibilityChange = ::onWalletSelectorVisibilityChange, + onNetworkSwitchClick = ::onNetworkSwitchClick, + onAnotherWalletSelect = { walletId -> + onWalletSelect(walletId) + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioWalletChanged(), + ) + }, + onContinueClick = { selectedWalletId, addedNetworks -> + onContinueClick(selectedWalletId, addedNetworks) + + // === Analytics === + analyticsEventHandler.send( + analyticsEventBuilder.addToPortfolioContinue( + blockchainNames = addedNetworks.mapNotNull { + BlockchainUtils.getNetworkInfo(it.networkId)?.name + }, + ), + ) + }, + ), + currentState = Provider { _state.value }, + tokenActionsHandler = tokenActionsHandler, + updateTokens = { updateBlock -> + updateTokensState { state -> + state.copy(tokens = updateBlock(state.tokens)) + } + }, + ) + + init { + if (accountsFeatureToggles.isFeatureEnabled) { + newAddToPortfolioManager = newAddToPortfolioManagerFactory + .create( + modelScope, + params.token, + params.analyticsParams, + ) + newMarketsPortfolioDelegate = newMarketsPortfolioDelegateFactory.create( + scope = modelScope, + token = params.token, + tokenActionsHandler = tokenActionsHandler, + buttonState = newAddToPortfolioManager.state.map { state -> + when (state) { + is NewAddToPortfolioManager.State.AvailableToAdd -> { + MyPortfolioUM.Tokens.AddButtonState.Available + } + NewAddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading + NewAddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable + } + }, + onAddClick = { + analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioClicked()) + bottomSheetNavigation.activate(MarketsPortfolioRoute.AddToPortfolio) + }, + ) + newMarketsPortfolioDelegate.combineData() + .onEach { _state.value = it } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } else { + newAddToPortfolioManager = null + newMarketsPortfolioDelegate = null + // Subscribe on selected wallet flow to support actual selected wallet + subscribeOnSelectedMultiWalletUpdates() + + subscribeOnStateUpdates() + } + } + + fun setTokenNetworks(networks: List) { + addToPortfolioManager.setAvailableNetworks(networks) + newAddToPortfolioManager?.setTokenNetworks(networks) + newMarketsPortfolioDelegate?.setTokenNetworks(networks) + } + + fun setNoNetworksAvailable() { + addToPortfolioManager.setAvailableNetworks(emptyList()) + newAddToPortfolioManager?.setTokenNetworks(emptyList()) + newMarketsPortfolioDelegate?.setTokenNetworks(emptyList()) + } + + private fun subscribeOnSelectedMultiWalletUpdates() { + getSelectedWalletUseCase() + .getOrElse { e -> + Timber.e("Failed to load selected wallet: $e") + error("Failed to load selected wallet") + } + .onEach { userWallet -> + selectedMultiWalletIdFlow.value = userWallet.takeIf { it.isMultiCurrency }?.walletId + } + .launchIn(modelScope) + } + + private fun subscribeOnStateUpdates() { + combine( + flow = loadPortfolioDataWithArtworks(params.token.id), + flow2 = getPortfolioUIDataFlow(), + transform = { pair, portfolioUIData -> + val (portfolioData, artworks) = pair + factory.create(portfolioData, portfolioUIData, artworks) + }, + ) + .onEach { _state.value = it } + .launchIn(modelScope) + } + + private fun loadPortfolioDataWithArtworks( + currencyRawId: CryptoCurrency.RawID, + ): Flow>> { + val wallets = Channel>() + val portfolioFlow = portfolioDataLoader + .load(currencyRawId) + .onEach { wallets.trySend(it.walletsWithCurrencies.keys) } + + val artworksFlow = wallets.receiveAsFlow() + .distinctUntilChanged() + .flatMapLatest { userWalletImageFetcher.walletsImage(wallets = it, size = ArtworkSize.SMALL) } + + return combine( + flow = portfolioFlow, + flow2 = artworksFlow, + ) { portfolioData, artworks -> portfolioData to artworks } + } + + private fun getPortfolioUIDataFlow(): Flow { + return combine( + flow = portfolioBSVisibilityModelFlow, + flow2 = selectedMultiWalletIdFlow, + flow3 = addToPortfolioManager.getAddToPortfolioData(), + transform = { portfolioBSVisibilityModel, selectedWalletId, addToPortfolioData -> + PortfolioUIData( + portfolioBSVisibilityModel = portfolioBSVisibilityModel, + selectedWalletId = selectedWalletId, + addToPortfolioData = addToPortfolioData, + isNeededColdWalletInteraction = isNeededColdWalletInteraction(selectedWalletId, addToPortfolioData), + ) + }, + ) + } + + private suspend fun isNeededColdWalletInteraction( + selectedWalletId: UserWalletId?, + addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, + ): Boolean { + return if (selectedWalletId != null) { + coldWalletAndHasMissedDerivationsUseCase.invoke( + userWalletId = selectedWalletId, + networksWithDerivationPath = addToPortfolioData.addedNetworks[selectedWalletId].orEmpty() + .associate { it.networkId to null }, + ) + } else { + false + } + } + + private fun onNetworkSwitchClick(blockchainRowUM: BlockchainRowUM, isChecked: Boolean) { + val selectedWalletId = selectedMultiWalletIdFlow.value + + if (selectedWalletId == null) { + Timber.e("Impossible to switch network when selected wallet is null") + return + } + + if (isChecked) { + modelScope.launch { + val unsupportedState = checkCurrencyUnsupportedState( + userWalletId = selectedWalletId, + rawNetworkId = blockchainRowUM.id, + isMainNetwork = blockchainRowUM.isMainNetwork, + ) + if (unsupportedState != null) { + showUnsupportedWarning(unsupportedState) + } else { + addToPortfolioManager.addNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id) + } + } + } else { + addToPortfolioManager.removeNetwork(userWalletId = selectedWalletId, networkId = blockchainRowUM.id) + } + } + + private suspend fun checkCurrencyUnsupportedState( + userWalletId: UserWalletId, + rawNetworkId: String, + isMainNetwork: Boolean, + ): CurrencyUnsupportedState? { + return checkCurrencyUnsupportedUseCase( + userWalletId = userWalletId, + networkId = rawNetworkId, + isMainNetwork = isMainNetwork, + ).getOrElse { throwable -> + Timber.e( + throwable, + """ + Failed to check currency unsupported state + |- User wallet ID: $userWalletId + |- Network ID: $rawNetworkId + |- Is main network: $isMainNetwork + """.trimIndent(), + ) + + val message = SnackbarMessage( + message = throwable.localizedMessage + ?.let(::stringReference) + ?: resourceReference(R.string.common_error), + ) + messageSender.send(message) + + null + } + } + + private fun showUnsupportedWarning(unsupportedState: CurrencyUnsupportedState) { + val message = DialogMessage( + message = when (unsupportedState) { + is CurrencyUnsupportedState.Token.NetworkTokensUnsupported -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.Token.UnsupportedCurve -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + is CurrencyUnsupportedState.UnsupportedNetwork -> resourceReference( + id = R.string.alert_manage_tokens_unsupported_curve_message, + formatArgs = wrappedList(unsupportedState.networkName), + ) + }, + ) + + messageSender.send(message) + } + + private fun onWalletSelect(userWalletId: UserWalletId) { + selectedMultiWalletIdFlow.update { prevUserWalletId -> + prevUserWalletId?.let(addToPortfolioManager::removeAllChanges) + + userWalletId + } + } + + private fun onContinueClick(userWalletId: UserWalletId, addedNetworks: Set) { + modelScope.launch { + saveMarketTokensUseCase( + userWalletId = userWalletId, + tokenMarketParams = params.token, + addedNetworks = addedNetworks, + removedNetworks = emptySet(), + ) + + onAddToPortfolioBSVisibilityChange(isShow = false) + + addToPortfolioManager.removeAllChanges(userWalletId) + } + } + + private fun onAddToPortfolioBSVisibilityChange(isShow: Boolean) { + portfolioBSVisibilityModelFlow.update { + it.copy(isAddToPortfolioBSVisible = isShow, isWalletSelectorBSVisible = false) + } + } + + private fun onWalletSelectorVisibilityChange(isShow: Boolean) { + portfolioBSVisibilityModelFlow.update { + it.copy(isAddToPortfolioBSVisible = true, isWalletSelectorBSVisible = isShow) + } + } + + private fun updateTokensState(block: (MyPortfolioUM.Tokens) -> MyPortfolioUM) { + _state.update { stateToUpdate -> + val tokensState = stateToUpdate as? MyPortfolioUM.Tokens ?: return@update stateToUpdate + block(tokensState) + } + } + + private fun configureReceiveAddresses(quickAction: TokenActionsHandler.HandledQuickAction) { + val isNewReceive = quickAction.action == TokenActionsBSContentUM.Action.Receive && + tokenReceiveFeatureToggle.isNewTokenReceiveEnabled + if (isNewReceive) { + modelScope.launch { + val tokenConfig = receiveAddressesFactory.create( + status = quickAction.cryptoCurrencyData.status, + userWalletId = quickAction.cryptoCurrencyData.userWallet.walletId, + ) ?: return@launch + bottomSheetNavigation.activate(MarketsPortfolioRoute.TokenReceive(tokenConfig)) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt new file mode 100644 index 0000000000..684f586b8a --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioRoute.kt @@ -0,0 +1,17 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.models.TokenReceiveConfig +import kotlinx.serialization.Serializable + +@Serializable +sealed interface MarketsPortfolioRoute : Route { + + @Serializable + data object AddToPortfolio : MarketsPortfolioRoute + + @Serializable + data class TokenReceive( + val config: TokenReceiveConfig, + ) : MarketsPortfolioRoute +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt new file mode 100644 index 0000000000..72ff24db98 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MyPortfolioUMFactory.kt @@ -0,0 +1,153 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.Provider +import kotlinx.collections.immutable.ImmutableList + +/** + * Factory for creating [MyPortfolioUM] + * + * @property onAddClick callback when user wants to add new token + * @property addToPortfolioBSContentUMFactory factory for creating add to portfolio bottom sheet content + * @property tokenActionsHandler token actions handler + * @property currentState current state provider + * @property updateTokens callback for updating tokens + * +[REDACTED_AUTHOR] + */ +internal class MyPortfolioUMFactory( + private val onAddClick: () -> Unit, + private val addToPortfolioBSContentUMFactory: AddToPortfolioBSContentUMFactory, + private val tokenActionsHandler: TokenActionsHandler, + private val currentState: Provider, + private val updateTokens: ((ImmutableList) -> ImmutableList) -> Unit, +) { + + fun create( + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + artworks: Map, + ): MyPortfolioUM { + val addToPortfolioData = portfolioUIData.addToPortfolioData + + val isOnlyUnavailableNetworks = addToPortfolioData.availableNetworks?.isEmpty() == true + if (isOnlyUnavailableNetworks) return MyPortfolioUM.Unavailable + + val walletsWithCurrencies = if (addToPortfolioData.availableNetworks == null) { + portfolioData.walletsWithCurrencies + } else { + portfolioData.walletsWithCurrencies.filterAvailableNetworks(networks = addToPortfolioData.availableNetworks) + } + + val isPortfolioEmpty = walletsWithCurrencies.flatMap { it.value }.isEmpty() + if (isPortfolioEmpty) { + val hasMultiWallets = walletsWithCurrencies.filterKeys(UserWallet::isMultiCurrency).isNotEmpty() + + return if (hasMultiWallets) { + MyPortfolioUM.AddFirstToken( + addToPortfolioBSConfig = createAddToPortfolioBSConfig( + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + artworks = artworks, + ), + onAddClick = onAddClick, + ) + } else { + MyPortfolioUM.UnavailableForWallet + } + } + + return TokensPortfolioUMConverter( + appCurrency = portfolioData.appCurrency, + isBalanceHidden = portfolioData.isBalanceHidden, + addButtonState = walletsWithCurrencies.getAddButtonState( + availableNetworks = addToPortfolioData.availableNetworks, + ), + bsConfig = createAddToPortfolioBSConfig( + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + artworks = artworks, + ), + onAddClick = onAddClick, + quickActionsIntents = tokenActionsHandler, + currentState = currentState, + updateTokens = updateTokens, + ) + .convert(walletsWithCurrencies) + } + + private fun createAddToPortfolioBSConfig( + portfolioData: PortfolioData, + portfolioUIData: PortfolioUIData, + artworks: Map, + ): TangemBottomSheetConfig { + val selectedWallet = portfolioData.walletsWithCurrencies.keys + .firstOrNull { it.walletId == portfolioUIData.selectedWalletId } + ?: portfolioData.walletsWithCurrencies.keys.firstOrNull { it.isMultiCurrency } + + val availableNetworks = portfolioUIData.addToPortfolioData.availableNetworks.orEmpty() + + val alreadyAddedNetworks = portfolioData.walletsWithCurrencies + .filterAvailableNetworks(availableNetworks)[selectedWallet] + ?.filter { !it.status.currency.isCustom } + ?.map { it.status.currency.network.backendId } + ?.toSet() + + return addToPortfolioBSContentUMFactory.create( + currentState = currentState().addToPortfolioBSConfig, + portfolioData = portfolioData, + portfolioUIData = portfolioUIData, + selectedWallet = selectedWallet, + alreadyAddedNetworks = alreadyAddedNetworks, + artworks = artworks, + ) + } + + private fun Map>.getAddButtonState( + availableNetworks: Set?, + ): MyPortfolioUM.Tokens.AddButtonState { + if (availableNetworks == null) return MyPortfolioUM.Tokens.AddButtonState.Loading + + val networkIds = availableNetworks.map { it.networkId } + + val isAllAvailableNetworksAdded = this + // User can add currencies only in multi-currency wallets + .filterKeys(UserWallet::isMultiCurrency) + .mapValues { entry -> entry.value.map { it.status.currency.network.backendId } } + // Each wallets contains all available networks? + .all { it.value.containsAll(networkIds) } + + return if (isAllAvailableNetworksAdded) { + MyPortfolioUM.Tokens.AddButtonState.Unavailable + } else { + MyPortfolioUM.Tokens.AddButtonState.Available + } + } + + /** Filter map values by available networks [networks] */ + private fun Map>.filterAvailableNetworks( + networks: Set, + ): Map> { + return mapValues { entry -> entry.value.filterAvailableNetworks(networks) } + } + + /** Filter list of [CryptoCurrencyStatus] by available networks [networks] */ + private fun List.filterAvailableNetworks( + networks: Set, + ): List { + val networkIds = networks.map(TokenMarketInfo.Network::networkId) + + return mapNotNull { cryptoCurrencyData -> + cryptoCurrencyData.takeIf { networkIds.contains(it.status.currency.network.backendId) } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt new file mode 100644 index 0000000000..9fdad17c66 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/NewMarketsPortfolioDelegate.kt @@ -0,0 +1,332 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +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.markets.TokenMarketParams +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +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.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioHeader +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioListItem +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletHeader +import com.tangem.utils.extensions.isZero +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LongParameterList") +internal class NewMarketsPortfolioDelegate @AssistedInject constructor( + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val allAccountSupplier: MultiAccountStatusListSupplier, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getUserWalletUseCase: GetUserWalletUseCase, + isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + @Assisted private val scope: CoroutineScope, + @Assisted private val token: TokenMarketParams, + @Assisted private val tokenActionsHandler: TokenActionsHandler, + @Assisted private val buttonState: Flow, + @Assisted private val onAddClick: () -> Unit, +) { + + private val currencyRawId: CryptoCurrency.RawID = token.id + private var expandedHolder: MutableStateFlow>>? = null + + private val settingsFlow: Flow = combine( + flow = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + flow3 = isAccountsModeEnabledUseCase(), + transform = ::SettingsBox, + ).shareIn( + replay = 1, + started = SharingStarted.Eagerly, + scope = scope, + ).distinctUntilChanged() + + private val availableNetworks = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + fun setTokenNetworks(networks: List) { + availableNetworks.tryEmit(networks) + } + + fun combineData(): Flow { + return availableNetworks.transformLatest { availableNetworks -> + when { + availableNetworks.isEmpty() -> emit(MyPortfolioUM.Unavailable) + else -> emitAll(onAvailableNetworksFlow().distinctUntilChanged()) + } + }.distinctUntilChanged() + } + + private fun onAvailableNetworksFlow(): Flow = + portfolioWithThisCurrencyFLow().transformLatest { portfolioWithCurrency -> + when (portfolioWithCurrency.flattenAddedCurrency.isEmpty()) { + false -> emitAll(contentFlow(portfolioWithCurrency).distinctUntilChanged()) + true -> when (portfolioWithCurrency.hasMultiWallets) { + true -> emitAll(addFirstTokenFlow()) + false -> emit(MyPortfolioUM.UnavailableForWallet) + } + } + } + + private fun addFirstTokenFlow(): Flow = buttonState.map { state -> + when (state) { + MyPortfolioUM.Tokens.AddButtonState.Loading -> MyPortfolioUM.Loading + MyPortfolioUM.Tokens.AddButtonState.Available -> MyPortfolioUM.AddFirstToken( + onAddClick = onAddClick, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + ) + MyPortfolioUM.Tokens.AddButtonState.Unavailable -> MyPortfolioUM.Unavailable + } + } + + private fun contentFlow(portfoliosWithThisCurrency: PortfoliosWithThisCurrency): Flow { + fun Portfolio.actionsFoAccountCurrencies(): List>> = + accountsWithAdded.map { account -> + fun CryptoCurrencyStatus.actionsFlow() = getCryptoCurrencyActionsUseCase( + accountId = account.accountStatus.account.accountId, + currency = this.currency, + ).map { actionsState -> actionsState.cryptoCurrencyStatus.currency to actionsState } + account.addedCurrency.map { it.actionsFlow() } + }.flatten() + + val allAddedTokenActions = + portfoliosWithThisCurrency.portfolios.map { portfolio -> portfolio.actionsFoAccountCurrencies() }.flatten() + + return combine( + flow = combine(allAddedTokenActions) { it.toMap() }.distinctUntilChanged(), + flow2 = buttonState.distinctUntilChanged(), + flow3 = getExpandedHolder(portfoliosWithThisCurrency), + flow4 = settingsFlow.distinctUntilChanged(), + transform = { actions, addButtonState, expanded, settings -> + buildContentState( + portfolio = portfoliosWithThisCurrency, + allActions = actions, + addButtonState = addButtonState, + expanded = expanded, + settings = settings, + ) + }, + ) + } + + private fun getExpandedHolder( + portfolio: PortfoliosWithThisCurrency, + ): StateFlow>> { + val expandedHolder = this.expandedHolder + if (expandedHolder != null) return expandedHolder + val allAddedCurrency = portfolio.flattenAddedCurrency + val shouldForceExpand = allAddedCurrency.size == 1 && + allAddedCurrency.first().value.amount?.isZero() == true + + val initValue = when { + shouldForceExpand -> { + val currency = allAddedCurrency.first() + // find userWallet than have this single added token + portfolio.portfolios + .find { it.accountsWithAdded.any { account -> account.addedCurrency.isNotEmpty() } } + ?.userWallet + ?.let { setOf(it.walletId to currency.currency.id) } + .orEmpty() + } + else -> emptySet() + } + return MutableStateFlow(initValue) + .also { this.expandedHolder = it } + } + + private fun portfolioWithThisCurrencyFLow(): Flow = + allAccountSupplier().map { list -> list.map { it.addedAccountsFlow() } }.flatMapLatest { flows -> + combine(flows) { portfolios -> + PortfoliosWithThisCurrency( + currencyRawId = currencyRawId, + portfolios = portfolios.toList(), + ) + } + }.distinctUntilChanged() + + private fun AccountStatusList.addedAccountsFlow(): Flow = + getUserWalletUseCase.invokeFlow(this.userWalletId).mapNotNull { it.getOrNull() }.map { wallet -> + Portfolio( + userWallet = wallet, + accountStatusList = this, + accountsWithAdded = this.filterByRawID(), + ) + }.distinctUntilChanged() + + private fun AccountStatusList.filterByRawID(): List { + fun AccountStatus.filterByRawID(): List = when (this) { + is AccountStatus.CryptoPortfolio -> this.tokenList.flattenCurrencies() + .filter { status -> status.currency.id.rawCurrencyId == currencyRawId } + } + return accountStatuses.map { accountStatus -> + AccountWithAdded( + accountStatus = accountStatus, + addedCurrency = accountStatus.filterByRawID(), + ) + } + } + + private fun buildContentState( + portfolio: PortfoliosWithThisCurrency, + allActions: Map, + addButtonState: MyPortfolioUM.Tokens.AddButtonState, + expanded: Set>, + settings: SettingsBox, + ): MyPortfolioUM.Content { + val appCurrency = settings.appCurrency + val isBalanceHidden = settings.isBalanceHidden + val isAccountMode = settings.isAccountMode + val uiItems: MutableList = mutableListOf() + + fun toggleQuickActions(key: Pair) = expandedHolder?.update { expanded -> + val isExpand = expanded.contains(key) + if (isExpand) expanded.minus(key) else expanded.plus(key) + } + + val tokenUMConverter = PortfolioTokenUMConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenItemClick = { }, + tokenActionsHandler = tokenActionsHandler, + ) + + portfolio.portfolios.forEach { portfolioItem -> + if (portfolioItem.flattenAddedCurrency.isEmpty()) return@forEach + val userWallet = portfolioItem.userWallet + if (isAccountMode) { + uiItems.add(portfolioItem.userWallet.toWalletHeader()) + } else { + uiItems.add(portfolioItem.userWallet.toWalletPortfolioHeader()) + } + + portfolioItem.accountsWithAdded.forEach { accountWithAdded -> + if (accountWithAdded.addedCurrency.isEmpty()) return@forEach + if (isAccountMode) { + val account = accountWithAdded.accountStatus.account + uiItems.add(account.toAccountPortfolioHeader()) + } + + accountWithAdded.addedCurrency.forEach { currencyStatus -> + val actions = allActions[currencyStatus.currency]?.states.orEmpty() + val value = PortfolioData.CryptoCurrencyData( + userWallet = userWallet, + status = currencyStatus, + actions = actions, + ) + val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id + val isExpand = expanded.contains(expandedKey) + + val tokenItem = tokenUMConverter.convertV2( + onTokenItemClick = { wallet, status -> + toggleQuickActions(wallet.walletId to status.currency.id) + }, + value = value, + isQuickActionsShown = isExpand, + ) + uiItems.add(tokenItem) + } + } + } + + return MyPortfolioUM.Content( + items = uiItems.toImmutableList(), + buttonState = addButtonState, + onAddClick = onAddClick, + ) + } + + private fun Account.toAccountPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.accountId.value, + state = AccountTitleUM.Account( + prefixText = TextReference.EMPTY, + name = this.accountName.toUM().value, + icon = when (this) { + is Account.CryptoPortfolio -> this.icon.toUM() + }, + ), + ) + + private fun UserWallet.toWalletPortfolioHeader(): PortfolioHeader = PortfolioHeader( + id = this.walletId.stringValue, + state = AccountTitleUM.Text( + title = stringReference(this.name), + ), + ) + + private fun UserWallet.toWalletHeader(): WalletHeader = WalletHeader( + id = this.walletId.stringValue, + name = stringReference(this.name), + ) + + @Suppress("LongParameterList") + @AssistedFactory + interface Factory { + fun create( + scope: CoroutineScope, + token: TokenMarketParams, + tokenActionsHandler: TokenActionsHandler, + buttonState: Flow, + onAddClick: () -> Unit, + ): NewMarketsPortfolioDelegate + } +} + +private data class PortfoliosWithThisCurrency( + val currencyRawId: CryptoCurrency.RawID, + val portfolios: List, +) { + + val hasMultiWallets: Boolean = portfolios.any { it.userWallet.isMultiCurrency } + + val flattenAddedCurrency: List = + portfolios.map { portfolio -> portfolio.flattenAddedCurrency }.flatten() +} + +private data class Portfolio( + val userWallet: UserWallet, + val accountStatusList: AccountStatusList, + val accountsWithAdded: List, +) { + val flattenAddedCurrency: List = + accountsWithAdded.map { it.addedCurrency }.flatten() +} + +private data class AccountWithAdded( + val addedCurrency: List, + val accountStatus: AccountStatus, +) + +private data class SettingsBox( + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val isAccountMode: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt new file mode 100644 index 0000000000..dd3f13b3e7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioBSVisibilityModel.kt @@ -0,0 +1,14 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +/** + * Model for portfolio bottom sheet visibility + * + * @property isAddToPortfolioBSVisible visibility of add to portfolio bottom sheet + * @property isWalletSelectorBSVisible visibility of wallet selector bottom sheet + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioBSVisibilityModel( + val isAddToPortfolioBSVisible: Boolean = false, + val isWalletSelectorBSVisible: Boolean = false, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt new file mode 100644 index 0000000000..8041823bc4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -0,0 +1,124 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM] + * +[REDACTED_AUTHOR] + */ +internal class PortfolioTokenUMConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit, + private val tokenActionsHandler: TokenActionsHandler, +) : Converter { + + fun convertV2( + value: PortfolioData.CryptoCurrencyData, + isQuickActionsShown: Boolean, + onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit, + ): PortfolioTokenUM { + val tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + onItemClick = { _, status -> onTokenItemClick(value.userWallet, status) }, + ) + return PortfolioTokenUM( + tokenItemState = tokenItemStateConverter.convert(value = value.status), + walletId = value.userWallet.walletId, + isBalanceHidden = isBalanceHidden, + isQuickActionsShown = isQuickActionsShown, + quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + ) + } + + override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM { + val tokenItemStateConverter = TokenItemStateConverter( + appCurrency = appCurrency, + titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) }, + subtitleStateProvider = { + TokenItemState.SubtitleState.TextContent(value = stringReference(value.status.currency.name)) + }, + onItemClick = { _, status -> onTokenItemClick(status) }, + ) + + return PortfolioTokenUM( + tokenItemState = tokenItemStateConverter.convert(value = value.status), + walletId = value.userWallet.walletId, + isBalanceHidden = isBalanceHidden, + isQuickActionsShown = false, + quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + ) + } + + companion object { + fun quickActions( + cryptoData: PortfolioData.CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + ): PortfolioTokenUM.QuickActions { + return PortfolioTokenUM.QuickActions( + actions = toQuickActions(cryptoData.actions), + onQuickActionClick = { quickActionUM -> + when (quickActionUM) { + QuickActionUM.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Stake -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Stake, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.YieldMode -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.YieldMode, + cryptoCurrencyData = cryptoData, + ) + } + }, + onQuickActionLongClick = { actionUM -> + if (actionUM == QuickActionUM.Receive) { + tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.CopyAddress, + cryptoCurrencyData = cryptoData, + ) + } + }, + ) + } + + fun toQuickActions(actions: List) = buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.showBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake + is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy) + else -> null + }?.let(::add) + } + } + }.toImmutableList() + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt new file mode 100644 index 0000000000..894b86dc98 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioUIData.kt @@ -0,0 +1,20 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Portfolio UI data. Combined data from all UI flows that required to setup portfolio + * + * @property portfolioBSVisibilityModel portfolio bottom sheet visibility model + * @property selectedWalletId selected wallet id + * @property addToPortfolioData add to portfolio data + * @property isNeededColdWalletInteraction flag that indicates if user has missed derivations and has a cold wallet + * +[REDACTED_AUTHOR] + */ +internal data class PortfolioUIData( + val portfolioBSVisibilityModel: PortfolioBSVisibilityModel, + val selectedWalletId: UserWalletId?, + val addToPortfolioData: AddToPortfolioManager.AddToPortfolioData, + val isNeededColdWalletInteraction: Boolean, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt new file mode 100644 index 0000000000..5f9c6b282e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/SelectNetworkUMConverter.kt @@ -0,0 +1,37 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +/** + * Converter from [TokenMarketParams] to [SelectNetworkUM] + * + * @property networksWithToggle map of networks with toggles + * @property alreadyAddedNetworks already added networks + * @property onNetworkSwitchClick callback is called when network switch is clicked + * +[REDACTED_AUTHOR] + */ +internal class SelectNetworkUMConverter( + private val networksWithToggle: Map, + private val alreadyAddedNetworks: Set, + private val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketParams): SelectNetworkUM { + return SelectNetworkUM( + tokenId = value.id.value, + iconUrl = value.imageUrl, + tokenName = value.name, + tokenCurrencySymbol = value.symbol, + networks = BlockchainRowUMConverter(alreadyAddedNetworks) + .convertList(networksWithToggle.toList()) + .toImmutableList(), + onNetworkSwitchClick = { um, isChecked -> onNetworkSwitchClick(um, isChecked) }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt new file mode 100644 index 0000000000..d9abe7979e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt @@ -0,0 +1,221 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.onramp.model.OnrampSource +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.tokens.legacy.TradeCryptoAction +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.feed.impl.R +import com.tangem.utils.Provider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList + +@Suppress("LongParameterList") +internal class TokenActionsHandler @AssistedInject constructor( + private val router: Router, + private val clipboardManager: ClipboardManager, + private val uiMessageSender: UiMessageSender, + private val reduxStateHolder: ReduxStateHolder, + @Assisted private val currentAppCurrency: Provider, + @Assisted private val updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit, + @Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit, + private val isDemoCardUseCase: IsDemoCardUseCase, + private val messageSender: UiMessageSender, + private val shareManager: ShareManager, +) { + + private val disabledActionsInDemoMode = buildSet { + add(TokenActionsBSContentUM.Action.Sell) + } + + fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + onHandleQuickAction( + HandledQuickAction( + action = action, + cryptoCurrencyData = cryptoCurrencyData, + ), + ) + val userWallet = cryptoCurrencyData.userWallet + if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return + + when (action) { + TokenActionsBSContentUM.Action.Buy -> onBuyClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Exchange -> onExchangeClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Receive -> onReceiveClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.CopyAddress -> onCopyAddress(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Sell -> onSellClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Send -> onSendClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.Stake -> onStakeClick(cryptoCurrencyData) + TokenActionsBSContentUM.Action.YieldMode -> onYieldModeClick(cryptoCurrencyData) + } + } + + private fun handleDemoMode(action: TokenActionsBSContentUM.Action, userWallet: UserWallet.Cold): Boolean { + val isDemoCard = isDemoCardUseCase.invoke(userWallet.cardId) + val isNeededShowDemoWarning = isDemoCard && disabledActionsInDemoMode.contains(action) + + if (isNeededShowDemoWarning) { + showDemoModeWarning() + } + + return isNeededShowDemoWarning + } + + private fun showDemoModeWarning() { + val message = DialogMessage( + message = resourceReference(R.string.alert_demo_feature_disabled), + ) + messageSender.send(message) + } + + private fun onReceiveClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val cryptoCurrencyStatus = cryptoCurrencyData.status + val currency = cryptoCurrencyStatus.currency + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return + + updateTokenReceiveBSConfig { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { + updateTokenReceiveBSConfig { + it.copy(isShown = false) + } + }, + content = TokenReceiveBottomSheetConfig( + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = networkAddress, + showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, + onCopyClick = { clipboardManager.setText(networkAddress.defaultAddress.value, isSensitive = true) }, + onShareClick = { shareManager.shareText(networkAddress.defaultAddress.value) }, + ), + ) + } + } + + private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val cryptoCurrencyStatus = cryptoCurrencyData.status + val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return + val addresses = networkAddress.availableAddresses + .mapToAddressModels(cryptoCurrencyStatus.currency) + .toImmutableList() + val defaultAddress = addresses.firstOrNull()?.value ?: return + + clipboardManager.setText(text = defaultAddress, isSensitive = true) + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied))) + } + + private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + router.push( + AppRoute.Onramp( + userWalletId = cryptoCurrencyData.userWallet.walletId, + currency = cryptoCurrencyData.status.currency, + source = OnrampSource.MARKETS, + ), + ) + } + + private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + reduxStateHolder.dispatch( + TradeCryptoAction.Sell( + cryptoCurrencyStatus = cryptoCurrencyData.status, + appCurrencyCode = currentAppCurrency().code, + ), + ) + } + + private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + router.push( + AppRoute.Swap( + currencyFrom = cryptoCurrencyData.status.currency, + userWalletId = cryptoCurrencyData.userWallet.walletId, + isInitialReverseOrder = true, + screenSource = AnalyticsParam.ScreensSources.Markets.value, + ), + ) + } + + private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val route = AppRoute.SendEntryPoint( + userWalletId = cryptoCurrencyData.userWallet.walletId, + currency = cryptoCurrencyData.status.currency, + ) + router.push(route) + } + + private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake } + ?.let { it as TokenActionsState.ActionState.Stake } + ?.option ?: return + + router.push( + AppRoute.Staking( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrency = cryptoCurrencyData.status.currency, + integrationId = option.integrationId, + ), + ) + } + + private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance() + .firstOrNull()?.apy ?: return + + val (userWalletId, cryptoCurrencyStatus) = cryptoCurrencyData.let { currencyData -> + currencyData.userWallet.walletId to currencyData.status + } + if (cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true) { + router.push( + AppRoute.YieldSupplyActive( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = yieldSupplyApy, + ), + ) + } else { + router.push( + AppRoute.YieldSupplyPromo( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = yieldSupplyApy, + ), + ) + } + } + + @AssistedFactory + interface Factory { + fun create( + currentAppCurrency: Provider, + updateTokenReceiveBSConfig: ((TangemBottomSheetConfig) -> TangemBottomSheetConfig) -> Unit, + onHandleQuickAction: (HandledQuickAction) -> Unit, + ): TokenActionsHandler + } + + data class HandledQuickAction( + val action: TokenActionsBSContentUM.Action, + val cryptoCurrencyData: PortfolioData.CryptoCurrencyData, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt new file mode 100644 index 0000000000..4444e6a82f --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokensPortfolioUMConverter.kt @@ -0,0 +1,116 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.model + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlin.collections.map + +/** + * Converter from [Map] of [UserWallet] and [CryptoCurrencyStatus] to [MyPortfolioUM.Tokens] + * +[REDACTED_AUTHOR] + */ +@Suppress("LongParameterList") +internal class TokensPortfolioUMConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val addButtonState: MyPortfolioUM.Tokens.AddButtonState, + private val bsConfig: TangemBottomSheetConfig, + private val onAddClick: () -> Unit, + private val quickActionsIntents: TokenActionsHandler, + private val currentState: Provider, + private val updateTokens: ((ImmutableList) -> ImmutableList) -> Unit, +) : Converter>, MyPortfolioUM.Tokens> { + + override fun convert(value: Map>): MyPortfolioUM.Tokens { + val currentTokensState = currentState() as? MyPortfolioUM.Tokens + + return MyPortfolioUM.Tokens( + tokens = value + .flatMap { entry -> entry.value } + .map { cryptoData -> + PortfolioTokenUMConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenItemClick = { toggleQuickActions(cryptoData) }, + tokenActionsHandler = quickActionsIntents, + ).convert(value = cryptoData) to cryptoData + } + .setQuickActionsVisibility(currentState = currentTokensState) + .toImmutableList(), + buttonState = addButtonState, + addToPortfolioBSConfig = bsConfig, + onAddClick = onAddClick, + tokenReceiveBSConfig = (currentState() as? MyPortfolioUM.Tokens) + ?.tokenReceiveBSConfig + ?: TangemBottomSheetConfig.Empty, + ) + } + + private fun List>.setQuickActionsVisibility( + currentState: MyPortfolioUM.Tokens?, + ): List { + return when { + // if there is only one token and it has empty balance, show quick actions for it + currentState == null && this.size == 1 && isEmptyBalance(this.first().second) -> { + this.map { (token, _) -> + token.copy(isQuickActionsShown = true) + } + } + // if there is no previous state, hide quick actions for all tokens + currentState == null -> { + this.map { (token, _) -> + token.copy(isQuickActionsShown = false) + } + } + else -> { + val previousList = currentState.tokens + + // otherwise, keep previous state + this.map { (token, _) -> + token.copy( + isQuickActionsShown = previousList + .firstOrNull { it.matchWith(token) } + ?.isQuickActionsShown == true, + ) + } + } + } + } + + private fun isEmptyBalance(cryptoData: PortfolioData.CryptoCurrencyData): Boolean { + return cryptoData.status.value.amount?.isZero() == true + } + + private fun toggleQuickActions(cryptoData: PortfolioData.CryptoCurrencyData) { + updateTokens { tokenList -> + tokenList.map { portfolioTokenUM -> + portfolioTokenUM.copy( + isQuickActionsShown = if (portfolioTokenUM.matchWith(cryptoData)) { + !portfolioTokenUM.isQuickActionsShown + } else { + false + }, + ) + }.toImmutableList() + } + } + + private fun PortfolioTokenUM.matchWith(token: PortfolioTokenUM): Boolean { + return this.walletId == token.walletId && this.tokenItemState.id == token.tokenItemState.id + } + + private fun PortfolioTokenUM.matchWith(cryptoData: PortfolioData.CryptoCurrencyData): Boolean { + return this.walletId == cryptoData.userWallet.walletId && + this.tokenItemState.id == cryptoData.status.currency.id.value + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt new file mode 100644 index 0000000000..16993ea52d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -0,0 +1,383 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.rows.ArrowRow +import com.tangem.core.ui.components.rows.BlockchainRow +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.coroutines.delay + +@Composable +internal fun AddToPortfolioBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + addBottomInsets = false, + titleText = resourceReference(R.string.common_add_to_portfolio), + ) { contentUM -> + Content( + modifier = Modifier.fillMaxWidth(), + state = contentUM, + ) + + WalletSelectorBottomSheet(contentUM.walletSelectorConfig) + } +} + +@Composable +private fun Content(state: AddToPortfolioBSContentUM, modifier: Modifier = Modifier) { + var continueButtonAreaHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val scrollState = rememberScrollState() + + Box(modifier = modifier) { + Column( + modifier = Modifier + .verticalScroll(state = scrollState) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + if (state.isWalletBlockVisible) { + UserWalletItem( + state = state.selectedWallet, + blockColors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + ) + SpacerH12() + } + + NetworkSelection( + modifier = Modifier.fillMaxWidth(), + state = state.selectNetworkUM, + ) + + SpacerH12() + + AnimatedVisibility( + visible = state.isScanCardNotificationVisible, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + ScanWalletWarning(modifier = Modifier.fillMaxWidth()) + SpacerH12() + } + + // Scroll to the bottom when the notification appears and the scroll is at the bottom + LaunchedEffect(Unit) { + if (scrollState.canScrollForward.not()) { + delay(timeMillis = 500) + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } + + SpacerH(with(density) { continueButtonAreaHeight.toDp() }) + } + + AnimatedVisibility( + visible = scrollState.canScrollForward, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + BottomFade(Modifier.align(Alignment.BottomCenter)) + } + + ContinueButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { + continueButtonAreaHeight = it.size.height + }, + enabled = state.isContinueButtonEnabled, + isTangemIconVisible = state.isScanCardNotificationVisible, + onClick = state.onContinueButtonClick, + ) + } +} + +@Composable +private fun ContinueButton( + enabled: Boolean, + isTangemIconVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TangemButton( + enabled = enabled, + modifier = modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ) + .navigationBarsPadding() + .fillMaxWidth(), + text = stringResourceSafe(R.string.common_continue), + icon = if (enabled && isTangemIconVisible) { + TangemButtonIconPosition.End(R.drawable.ic_tangem_24) + } else { + TangemButtonIconPosition.None + }, + showProgress = false, + size = TangemButtonSize.Default, + colors = TangemButtonsDefaults.primaryButtonColors, + textStyle = TangemTheme.typography.subtitle1, + onClick = onClick, + animateContentChange = true, + ) +} + +@Suppress("LongMethod") +@Composable +private fun NetworkSelection(state: SelectNetworkUM, modifier: Modifier = Modifier) { + val hapticManager = LocalHapticManager.current + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResourceSafe(R.string.markets_select_network), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens.spacing14), + verticalAlignment = Alignment.CenterVertically, + ) { + CoinIcon( + modifier = Modifier.size(TangemTheme.dimens.size36), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + SpacerW12() + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .weight(1f, fill = false) + .alignByBaseline(), + text = state.tokenName, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + SpacerW6() + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .alignByBaseline(), + text = state.tokenCurrencySymbol, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + overflow = TextOverflow.Visible, + maxLines = 1, + ) + } + + state.networks.fastForEachIndexed { index, network -> + ArrowRow( + isLastItem = index == state.networks.lastIndex, + content = { + BlockchainRow( + modifier = Modifier.padding(end = TangemTheme.dimens.spacing4), + model = network, + action = { + TangemSwitch( + checked = network.isSelected, + checkedColor = if (network.isEnabled) { + TangemTheme.colors.control.checked + } else { + TangemTheme.colors.icon.inactive + }, + onCheckedChange = { checked -> + if (checked) { + hapticManager.perform(TangemHapticEffect.View.ToggleOn) + } else { + hapticManager.perform(TangemHapticEffect.View.ToggleOff) + } + + state.onNetworkSwitchClick(network, checked) + }, + enabled = network.isEnabled, + ) + }, + ) + }, + ) + } + } + } +} + +@Composable +private fun ScanWalletWarning(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background( + color = TangemTheme.colors.button.disabled, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10), + ) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size20), + imageVector = ImageVector.vectorResource(R.drawable.ic_tangem_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + ) + Text( + text = stringResourceSafe(R.string.markets_generate_addresses_notification), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview { + AddToPortfolioBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + content = content, + onDismissRequest = {}, + ), + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewContent( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview { + Content( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth(), + state = content, + ) + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewContentRtl( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview(rtl = true) { + Content( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .fillMaxWidth(), + state = content, + ) + } +} + +// For on device testing +@Composable +@Preview +private fun PreviewContentTestOnDevice( + @PreviewParameter(PreviewAddToPortfolioBSContentProvider::class) content: AddToPortfolioBSContentUM, +) { + TangemThemePreview( + alwaysShowBottomSheets = false, + ) { + var isShow by remember { mutableStateOf(false) } + + var contentState by remember { + mutableStateOf(content) + } + + LaunchedEffect(Unit) { + contentState = content.copy( + onContinueButtonClick = { + contentState = contentState.copy( + isScanCardNotificationVisible = !contentState.isScanCardNotificationVisible, + ) + }, + isContinueButtonEnabled = true, + selectedWallet = content.selectedWallet.copy( + onClick = { + contentState = contentState.copy( + isContinueButtonEnabled = !contentState.isContinueButtonEnabled, + ) + }, + ), + ) + } + + AddToPortfolioBottomSheet( + config = TangemBottomSheetConfig( + isShown = isShow, + content = contentState, + onDismissRequest = { isShow = false }, + ), + ) + + Button( + onClick = { isShow = !isShow }, + ) { + Text(text = "Toggle") + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt new file mode 100644 index 0000000000..7ff006cdb9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/MyPortfolio.kt @@ -0,0 +1,341 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.common.ui.account.AccountTitle +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.* +import com.tangem.features.feed.impl.R + +@Composable +internal fun MyPortfolio(state: MyPortfolioUM, modifier: Modifier = Modifier) { + if (state is MyPortfolioUM.Content) { + val contentModifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing20, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing32, + ) + PortfolioList(state, contentModifier) + return + } + InformationBlock( + modifier = modifier, + contentHorizontalPadding = TangemTheme.dimens.spacing0, + title = { Title() }, + action = { + if (state !is MyPortfolioUM.Tokens) return@InformationBlock + + AddButton(state = state.buttonState, onClick = state.onAddClick) + }, + ) { + val contentModifier = Modifier.padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + ) + + when (state) { + is MyPortfolioUM.Tokens -> TokenList(state = state) + is MyPortfolioUM.AddFirstToken -> AddFirstTokenContent(state = state, modifier = contentModifier) + MyPortfolioUM.Loading -> LoadingPlaceholder(modifier = contentModifier) + MyPortfolioUM.Unavailable -> UnavailableAsset(modifier = contentModifier) + MyPortfolioUM.UnavailableForWallet -> UnavailableAssetForWallet(modifier = contentModifier) + is MyPortfolioUM.Content -> PortfolioList(state = state) + } + } + + val bsConfig = state.addToPortfolioBSConfig + if (bsConfig != null) { + AddToPortfolioBottomSheet(config = bsConfig) + } +} + +@Composable +private fun Title() { + Text( + text = stringResourceSafe(R.string.markets_common_my_portfolio), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun AddButton(state: MyPortfolioUM.Tokens.AddButtonState, onClick: () -> Unit) { + when (state) { + MyPortfolioUM.Tokens.AddButtonState.Loading -> { + Box { + SmallButtonShimmer( + modifier = Modifier.width(width = TangemTheme.dimens.size63), + shape = RoundedCornerShape(TangemTheme.dimens.radius3), + withIcon = true, + ) + + Box( + Modifier + .matchParentSize() + .background(TangemTheme.colors.background.action), + ) + + RectangleShimmer( + modifier = Modifier + .align(Alignment.Center) + .size(width = TangemTheme.dimens.size63, height = TangemTheme.dimens.size18), + radius = TangemTheme.dimens.radius3, + ) + } + } + MyPortfolioUM.Tokens.AddButtonState.Available, + MyPortfolioUM.Tokens.AddButtonState.Unavailable, + -> { + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.markets_add_token), + icon = TangemButtonIconPosition.Start(R.drawable.ic_plus_24), + onClick = onClick, + isEnabled = state == MyPortfolioUM.Tokens.AddButtonState.Available, + ), + ) + } + } +} + +@Composable +private fun TokenList(state: MyPortfolioUM.Tokens, modifier: Modifier = Modifier) { + Column(modifier) { + state.tokens.fastForEachIndexed { index, token -> + key(token.tokenItemState.id) { + PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + state = token, + lastInList = index == state.tokens.size - 1, + ) + } + } + } + + TokenReceiveBottomSheet(config = state.tokenReceiveBSConfig) +} + +@Composable +private fun PortfolioList(state: MyPortfolioUM.Content, modifier: Modifier = Modifier) { + Column(modifier) { + key("PortfolioListHeader") { + Row( + modifier = Modifier.padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(R.string.markets_common_my_portfolio), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + AddButton(state = state.buttonState, onClick = state.onAddClick) + } + } + + state.items.fastForEachIndexed { index, item -> + val previousItem = state.items.getOrNull(index.dec()) + val nextItem = state.items.getOrNull(index.inc()) + val itemModifier = Modifier + .fillMaxWidth() + .getOffsetModifier(item, previousItem) + .getBackgroundModifier(item, previousItem, nextItem) + + key(item.id) { + PortfolioItem( + item = item, + modifier = itemModifier, + lastInList = index == state.items.size - 1, + ) + } + } + } +} + +@Composable +private fun Modifier.getBackgroundModifier( + item: PortfolioListItem, + previousItem: PortfolioListItem?, + nextItem: PortfolioListItem?, +): Modifier { + val color = TangemTheme.colors.background.action + val radius = 14.dp + val topRound = RoundedCornerShape(topStart = radius, topEnd = radius) + val bottomRound = RoundedCornerShape(bottomStart = radius, bottomEnd = radius) + val allRound = RoundedCornerShape(size = radius) + val backgroundModifier = when (item) { + is WalletHeader -> this + is PortfolioHeader -> this + .clip(topRound) + .background(color = color) + is PortfolioTokenUM -> when { + previousItem is PortfolioHeader && nextItem !is PortfolioTokenUM -> this + .clip(bottomRound) + .background(color = color) + previousItem is WalletHeader && nextItem !is PortfolioTokenUM -> this + .clip(allRound) + .background(color = color) + previousItem is PortfolioTokenUM && nextItem !is PortfolioTokenUM -> this + .clip(bottomRound) + .background(color = color) + else -> this.background(color = color) + } + } + return backgroundModifier +} + +private fun Modifier.getOffsetModifier(item: PortfolioListItem, previousItem: PortfolioListItem?): Modifier = when { + item is WalletHeader -> this.padding(top = 20.dp, start = 4.dp, end = 4.dp) + item is PortfolioHeader && previousItem is PortfolioTokenUM -> this.padding(top = 12.dp) + item is PortfolioHeader && previousItem == null -> this.padding(top = 20.dp) + previousItem is WalletHeader -> this.padding(top = 12.dp) + previousItem is PortfolioTokenUM -> this + else -> this +} + +@Composable +private fun PortfolioItem(item: PortfolioListItem, lastInList: Boolean, modifier: Modifier = Modifier) { + when (item) { + is PortfolioHeader -> AccountTitle( + modifier = modifier.padding( + start = 12.dp, + top = 12.dp, + bottom = 8.dp, + ), + accountTitleUM = item.state, + textStyle = TangemTheme.typography.caption1, + textColor = TangemTheme.colors.text.primary1, + ) + is PortfolioTokenUM -> PortfolioItem( + state = item, + modifier = modifier, + lastInList = lastInList, + ) + is WalletHeader -> Text( + modifier = modifier, + text = item.name.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +fun UnavailableAsset(modifier: Modifier = Modifier) { + UnavailableContent( + textId = R.string.markets_add_to_my_portfolio_unavailable_description, + modifier = modifier, + ) +} + +@Composable +fun UnavailableAssetForWallet(modifier: Modifier = Modifier) { + UnavailableContent( + textId = R.string.markets_add_to_my_portfolio_unavailable_for_wallet_description, + modifier = modifier, + ) +} + +@Composable +private fun UnavailableContent(@StringRes textId: Int, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = stringResourceSafe(textId), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +private fun AddFirstTokenContent(state: MyPortfolioUM.AddFirstToken, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Text( + text = stringResourceSafe(R.string.markets_add_to_my_portfolio_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.common_add_to_portfolio), + onClick = state.onAddClick, + ) + } +} + +@Composable +private fun LoadingPlaceholder(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) { + TangemThemePreview { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary), + ) { + MyPortfolio(state) + } + } +} + +@Preview +@Composable +private fun PreviewRtl(@PreviewParameter(PreviewMyPortfolioUMProvider::class) state: MyPortfolioUM) { + TangemThemePreview(rtl = true) { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing8), + ) { + MyPortfolio(state) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt new file mode 100644 index 0000000000..9faba4479e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioItem.kt @@ -0,0 +1,153 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewMyPortfolioUMProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM +import com.tangem.features.feed.impl.R +import com.tangem.utils.StringsSigns.DASH_SIGN +import kotlinx.collections.immutable.persistentListOf +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState + +@Composable +internal fun PortfolioItem(state: PortfolioTokenUM, lastInList: Boolean, modifier: Modifier = Modifier) { + Column(modifier) { + val hapticManager = LocalHapticManager.current + val tokenItemState = remember(state.tokenItemState) { + when (state.tokenItemState) { + is TokenItemState.Content -> state.tokenItemState.copy( + onItemClick = { tokenItemState -> + val onClick = state.tokenItemState.onItemClick + if (onClick != null) { + hapticManager.perform(TangemHapticEffect.View.ContextClick) + onClick.invoke(tokenItemState) + } + }, + ) + else -> state.tokenItemState + } + } + TokenItem( + state = tokenItemState, + isBalanceHidden = state.isBalanceHidden, + itemPaddingValues = PaddingValues( + start = TangemTheme.dimens.spacing10, + end = TangemTheme.dimens.spacing12, + ), + ) + + PortfolioQuickActions( + modifier = Modifier + .padding( + bottom = if (lastInList) { + TangemTheme.dimens.spacing12 + } else { + TangemTheme.dimens.spacing24 + }, + ), + actions = state.quickActions.actions, + isVisible = state.isQuickActionsShown, + onActionClick = state.quickActions.onQuickActionClick, + onActionLongClick = state.quickActions.onQuickActionLongClick, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PortfolioTokenUMProvider::class) tokenUM: PortfolioTokenUM) { + TangemThemePreview { + var isQuickActionsShown by remember { mutableStateOf(value = false) } + + val onItemClick = { + isQuickActionsShown = isQuickActionsShown.not() + } + + PortfolioItem( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + state = tokenUM.copy( + tokenItemState = when (tokenUM.tokenItemState) { + is TokenItemState.Content -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) + is TokenItemState.Unreachable -> tokenUM.tokenItemState.copy(onItemClick = { onItemClick() }) + else -> tokenUM.tokenItemState + }, + isQuickActionsShown = isQuickActionsShown, + ), + lastInList = true, + ) + } +} + +private class PortfolioTokenUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + tokenUM.copy( + tokenItemState = (tokenUM.tokenItemState as TokenItemState.Content).copy( + fiatAmountState = contentFiatAmount?.copy( + icons = persistentListOf( + TokenFiatAmountState.Content.IconUM( + iconRes = R.drawable.ic_staking_24, + tint = IconTint.Accent, + ), + ), + ), + ), + ), + tokenUM.copy( + tokenItemState = tokenUM.tokenItemState.copy( + fiatAmountState = contentFiatAmount?.copy(text = DASH_SIGN), + subtitle2State = (tokenUM.tokenItemState.subtitle2State as? TokenItemState.Subtitle2State.TextContent) + ?.copy(text = DASH_SIGN), + ), + ), + tokenUM.copy(isBalanceHidden = true), + tokenUM.copy( + tokenItemState = TokenItemState.Unreachable( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState, + subtitleState = tokenUM.tokenItemState.subtitleState, + onItemClick = {}, + onItemLongClick = {}, + ), + ), + tokenUM.copy( + tokenItemState = TokenItemState.NoAddress( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState, + subtitleState = tokenUM.tokenItemState.subtitleState, + onItemLongClick = {}, + ), + ), + tokenUM.copy( + tokenItemState = TokenItemState.Loading( + id = tokenUM.tokenItemState.id, + iconState = tokenUM.tokenItemState.iconState, + titleState = tokenUM.tokenItemState.titleState as TokenItemState.TitleState.Content, + subtitleState = tokenUM.tokenItemState.subtitleState, + ), + ), + ), +) { + + companion object { + val tokenUM = PreviewMyPortfolioUMProvider().sampleToken + val contentFiatAmount = tokenUM.tokenItemState.fiatAmountState as? TokenFiatAmountState.Content + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt new file mode 100644 index 0000000000..ffed264514 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt @@ -0,0 +1,268 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.components.icons.badge.drawBadge +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PortfolioQuickActions( + actions: ImmutableList, + isVisible: Boolean, + onActionClick: (QuickActionUM) -> Unit, + onActionLongClick: (QuickActionUM) -> Unit, + modifier: Modifier = Modifier, +) { + if (actions.isEmpty()) return + + AnimatedVisibility( + modifier = modifier, + visible = isVisible, + enter = expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top), + ) { + Column { + actions.fastForEach { action -> + LineSeparator() + QuickActionItem( + state = action, + onClick = { onActionClick(action) }, + onLongClick = { onActionLongClick(action) }.takeIf { action.isLongClickAvailable }, + ) + } + } + } +} + +@Composable +private fun AnimatedVisibilityScope.LineSeparator(modifier: Modifier = Modifier) { + val lineColor = TangemTheme.colors.stroke.primary + val strokeWidth = TangemTheme.dimens.size1 + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val startPadding = TangemTheme.dimens.spacing30 + + val height = TangemTheme.dimens.size16 + + Canvas( + modifier = modifier + .animateEnterExit( + enter = expandVertically( + animationSpec = spring( + stiffness = Spring.StiffnessLow, + ), + expandFrom = Alignment.Top, + ) + fadeIn(), + exit = shrinkVertically( + spring( + stiffness = Spring.StiffnessLow, + ), + shrinkTowards = Alignment.Top, + ) + fadeOut(), + ) + .fillMaxWidth() + .height(height), + ) { + val x = if (isLtr) startPadding.toPx() else size.width - startPadding.toPx() + + drawLine( + color = lineColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = strokeWidth.toPx(), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AnimatedVisibilityScope.QuickActionItem( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal: (() -> Unit)? = if (onLongClick != null) { + { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + } else { + null + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onLongClick = onLongClickInternal, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .padding(horizontal = TangemTheme.dimens.spacing14, vertical = TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing18), + ) { + QuickActionIcon(state) + Column( + modifier = Modifier + .animateEnterExit( + enter = fadeIn(), + exit = fadeOut(), + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +@Composable +private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { + val containerColor = TangemTheme.colors.background.action + Box( + Modifier + .animateEnterExit( + enter = scaleIn(), + exit = scaleOut(), + ) + .background( + color = TangemTheme.colors.button.secondary, + shape = CircleShape, + ) + .size(TangemTheme.dimens.size32) + .drawWithContent { + drawContent() + if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + drawBadge(containerColor = containerColor, offset = 4.dp) + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .requiredSize(TangemTheme.dimens.size16), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors.button.primary, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + var isVisible by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxWidth() + .height(680.dp), + ) { + Button( + onClick = { isVisible = !isVisible }, + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) { + Text(text = "Toggle") + } + SpacerH4() + Box( + modifier = Modifier.background(color = TangemTheme.colors.background.action), + ) { + PortfolioQuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + isVisible = isVisible, + onActionClick = {}, + onActionLongClick = {}, + ) + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewRtl() { + TangemThemePreview(rtl = true) { + Box(modifier = Modifier.background(color = TangemTheme.colors.background.action)) { + PortfolioQuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + isVisible = true, + onActionClick = {}, + onActionLongClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt new file mode 100644 index 0000000000..7b4a266b9e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/TokenActionsBottomSheet.kt @@ -0,0 +1,86 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.SimpleSettingsRow +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun TokenActionsBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + title = { content -> + TangemBottomSheetTitle(content.title) + }, + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: TokenActionsBSContentUM) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + content.actions.forEachIndexed { index, action -> + Box( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = content.actions.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action), + ) { + SimpleSettingsRow( + title = action.text.resolveReference(), + icon = action.iconRes, + redesign = true, + onItemsClick = { content.onActionClick(action) }, + ) + } + } + } +} + +@Preview(widthDp = 360, heightDp = 640) +@Preview(widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview( + alwaysShowBottomSheets = true, + ) { + Box(Modifier.background(TangemTheme.colors.background.secondary)) { + TokenActionsBottomSheet( + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TokenActionsBSContentUM( + title = "Wallet 1", + actions = TokenActionsBSContentUM.Action.entries.toImmutableList(), + onActionClick = {}, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt new file mode 100644 index 0000000000..530786cf30 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/WalletSelectorBottomSheet.kt @@ -0,0 +1,145 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.userwallet.UserWalletItem +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.block.TangemBlockCardColors +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview.PreviewAddToPortfolioBSContentProvider +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.WalletSelectorBSContentUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun WalletSelectorBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + addBottomInsets = false, + title = { content -> + TangemTopAppBar( + title = resourceReference(R.string.common_choose_wallet), + titleAlignment = Alignment.CenterHorizontally, + startButton = TopAppBarButtonUM.Back(content.onBack), + height = TangemTopAppBarHeight.BOTTOM_SHEET, + ) + }, + ) { content -> + Content( + modifier = Modifier + .fillMaxSize() + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing8, + ), + state = content, + ) + } +} + +@Composable +private fun Content(state: WalletSelectorBSContentUM, modifier: Modifier = Modifier) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + ) { + BlockCard( + modifier = Modifier.fillMaxSize(), + colors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + ) { + state.userWallets.forEach { state -> + key(state.id) { + UserWalletItem( + modifier = Modifier.fillMaxWidth(), + blockColors = TangemBlockCardColors.copy( + containerColor = TangemTheme.colors.background.action, + disabledContainerColor = TangemTheme.colors.background.action, + ), + state = state, + ) + } + } + } + SpacerH(bottomBarHeight) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + WalletSelectorBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = WalletSelectorBSContentUM( + userWallets = persistentListOf( + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.Checkmark, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + ), + onBack = {}, + ), + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewContent() { + TangemThemePreview { + Content( + state = WalletSelectorBSContentUM( + userWallets = persistentListOf( + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.Checkmark, + ), + PreviewAddToPortfolioBSContentProvider().userWallet.copy( + endIcon = UserWalletItemUM.EndIcon.None, + ), + ), + onBack = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt new file mode 100644 index 0000000000..4de442ca27 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -0,0 +1,86 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.AddToPortfolioBSContentUM +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.SelectNetworkUM +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.persistentListOf + +internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = "1", + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = false, + isSelected = false, + ) + + val userWallet = UserWalletItemUM( + id = "1", + name = stringReference("Wallet 1"), + information = UserWalletItemUM.Information.Loaded(TextReference.Str("3 cards")), + balance = UserWalletItemUM.Balance.Loading, + isEnabled = true, + endIcon = UserWalletItemUM.EndIcon.Arrow, + onClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + AddToPortfolioBSContentUM( + selectedWallet = userWallet, + selectNetworkUM = SelectNetworkUM( + tokenId = "etherium", + tokenName = "Etherium", + tokenCurrencySymbol = "ETH", + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + isSelected = true, + ), + blockchainRow, + blockchainRow, + ), + onNetworkSwitchClick = { _, _ -> }, + iconUrl = null, + ), + isScanCardNotificationVisible = true, + isWalletBlockVisible = true, + isContinueButtonEnabled = true, + onContinueButtonClick = {}, + walletSelectorConfig = TangemBottomSheetConfig.Empty, + ), + AddToPortfolioBSContentUM( + selectedWallet = userWallet, + selectNetworkUM = SelectNetworkUM( + tokenId = "etherium", + tokenName = "Etherium Etherium Etherium Etherium", + tokenCurrencySymbol = "ETH", + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + isSelected = true, + ).copy(name = "Etherium Etherium Etherium Etherium"), + *Array(25) { blockchainRow }, + ), + + onNetworkSwitchClick = { _, _ -> }, + iconUrl = null, + ), + isScanCardNotificationVisible = true, + isWalletBlockVisible = false, + isContinueButtonEnabled = false, + onContinueButtonClick = {}, + walletSelectorConfig = TangemBottomSheetConfig.Empty, + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt new file mode 100644 index 0000000000..3a40be94b8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -0,0 +1,150 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.account.AccountIconPreviewData +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.* +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +@Suppress("PropertyUsedBeforeDeclaration") +internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider { + + override val values: Sequence + get() = sequenceOf( + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken, sampleToken), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken, sampleToken.copy(isQuickActionsShown = true)), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Unavailable, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Tokens( + tokens = persistentListOf(sampleToken.copy(isQuickActionsShown = true), sampleToken), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Loading, + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + tokenReceiveBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.AddFirstToken( + addToPortfolioBSConfig = TangemBottomSheetConfig.Empty, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletPortfolioHeader, + accountToken, + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletHeader, + accountHeader, + accountToken, + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Content( + items = persistentListOf( + walletHeader, + accountHeader, + accountToken.copy(isQuickActionsShown = true), + accountToken, + ), + buttonState = MyPortfolioUM.Tokens.AddButtonState.Available, + onAddClick = {}, + ), + MyPortfolioUM.Loading, + MyPortfolioUM.Unavailable, + MyPortfolioUM.UnavailableForWallet, + ) + + val walletHeader + get() = WalletHeader( + id = UUID.randomUUID().toString(), + name = stringReference("Wallet 1"), + ) + + val walletPortfolioHeader + get() = PortfolioHeader( + state = AccountTitleUM.Text(title = stringReference("Wallet 1")), + id = UUID.randomUUID().toString(), + ) + + val accountHeader + get() = PortfolioHeader( + state = AccountTitleUM.Account( + icon = AccountIconPreviewData.randomAccountIcon(), + name = stringReference("Main Account"), + prefixText = TextReference.EMPTY, + ), + id = UUID.randomUUID().toString(), + ) + val coinIconState + get() = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.img_polygon_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ) + val accountToken + get() = sampleToken.copy( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = coinIconState, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")), + fiatAmountState = FiatAmountState.Content(text = "321 $"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "5,412 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(value = "Token")), + onItemClick = {}, + onItemLongClick = {}, + ), + ) + + val sampleToken + get() = PortfolioTokenUM( + tokenItemState = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.Locked, + titleState = TokenItemState.TitleState.Content(text = stringReference(value = "My wallet")), + fiatAmountState = FiatAmountState.Content(text = "486,65 \$"), + subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "733,71097 MATIC"), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = stringReference(value = "XRP Ledger token"), + ), + onItemClick = {}, + onItemLongClick = {}, + ), + isQuickActionsShown = false, + quickActions = PortfolioTokenUM.QuickActions( + actions = persistentListOf( + QuickActionUM.Buy, + QuickActionUM.Exchange(shouldShowBadge = true), + QuickActionUM.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + isBalanceHidden = false, + walletId = UserWalletId(""), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt new file mode 100644 index 0000000000..11e574c9e7 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/AddToPortfolioBSContentUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +internal data class AddToPortfolioBSContentUM( + val selectedWallet: UserWalletItemUM, + val selectNetworkUM: SelectNetworkUM, + val isWalletBlockVisible: Boolean, + val isScanCardNotificationVisible: Boolean, + val isContinueButtonEnabled: Boolean, + val onContinueButtonClick: () -> Unit, + val walletSelectorConfig: TangemBottomSheetConfig, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt new file mode 100644 index 0000000000..010f7023cf --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/MyPortfolioUM.kt @@ -0,0 +1,52 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class MyPortfolioUM { + + abstract val addToPortfolioBSConfig: TangemBottomSheetConfig? + + data class Tokens( + override val addToPortfolioBSConfig: TangemBottomSheetConfig, + val tokens: ImmutableList, + val buttonState: AddButtonState, + val tokenReceiveBSConfig: TangemBottomSheetConfig, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() { + + enum class AddButtonState { + Loading, + Available, + Unavailable, + } + } + + data class Content( + val items: ImmutableList, + val buttonState: Tokens.AddButtonState, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() { + + override val addToPortfolioBSConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty + } + + data class AddFirstToken( + override val addToPortfolioBSConfig: TangemBottomSheetConfig, + val onAddClick: () -> Unit, + ) : MyPortfolioUM() + + data object Loading : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } + + data object Unavailable : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } + + data object UnavailableForWallet : MyPortfolioUM() { + override val addToPortfolioBSConfig: TangemBottomSheetConfig? = null + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt new file mode 100644 index 0000000000..c4817c30c2 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/PortfolioTokenUM.kt @@ -0,0 +1,39 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface PortfolioListItem { + val id: String +} + +internal data class WalletHeader( + override val id: String, + val name: TextReference, +) : PortfolioListItem + +internal data class PortfolioHeader( + override val id: String, + val state: AccountTitleUM, +) : PortfolioListItem + +internal data class PortfolioTokenUM( + val tokenItemState: TokenItemState, + val walletId: UserWalletId, + val isBalanceHidden: Boolean, + val isQuickActionsShown: Boolean, + val quickActions: QuickActions, +) : PortfolioListItem { + override val id: String = tokenItemState.id + + data class QuickActions( + val actions: ImmutableList, + val onQuickActionClick: (QuickActionUM) -> Unit, + val onQuickActionLongClick: (QuickActionUM) -> Unit, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt new file mode 100644 index 0000000000..d9ba4e54b8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt @@ -0,0 +1,51 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.feed.impl.R + +@Immutable +internal sealed class QuickActionUM( + val title: TextReference, + val description: TextReference, + @DrawableRes val icon: Int, + val isLongClickAvailable: Boolean = false, +) { + data object Buy : QuickActionUM( + title = resourceReference(R.string.common_buy), + description = resourceReference(R.string.buy_token_description), + icon = R.drawable.ic_plus_24, + ) + + data class Exchange( + val shouldShowBadge: Boolean, + ) : QuickActionUM( + title = resourceReference(R.string.common_exchange), + description = resourceReference(R.string.exсhange_token_description), + icon = R.drawable.ic_exchange_vertical_24, + ) + + data object Receive : QuickActionUM( + title = resourceReference(R.string.common_receive), + description = resourceReference(R.string.receive_token_description), + icon = R.drawable.ic_arrow_down_24, + isLongClickAvailable = true, + ) + + data object Stake : QuickActionUM( + title = resourceReference(R.string.common_stake), + description = resourceReference(R.string.stake_token_description), + icon = R.drawable.ic_staking_24, + ) + + data class YieldMode( + private val apy: String, + ) : QuickActionUM( + title = resourceReference(R.string.yield_module_start_earning), + description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), + icon = R.drawable.ic_analytics_up_mini_24, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt new file mode 100644 index 0000000000..4ee09913a4 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/SelectNetworkUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import kotlinx.collections.immutable.ImmutableList + +internal data class SelectNetworkUM( + val tokenId: String, + val iconUrl: String?, + val tokenName: String, + val tokenCurrencySymbol: String, + val networks: ImmutableList, + val onNetworkSwitchClick: (BlockchainRowUM, Boolean) -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt new file mode 100644 index 0000000000..b02f6a8266 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt @@ -0,0 +1,58 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.feed.impl.R +import kotlinx.collections.immutable.ImmutableList + +internal data class TokenActionsBSContentUM( + val title: String, + val actions: ImmutableList, + val onActionClick: (Action) -> Unit, +) : TangemBottomSheetConfigContent { + + @Immutable + enum class Action( + val text: TextReference, + @DrawableRes val iconRes: Int, + ) { + CopyAddress( + text = resourceReference(R.string.common_copy_address), + iconRes = R.drawable.ic_copy_24, + ), + Send( + text = resourceReference(R.string.common_send), + iconRes = R.drawable.ic_arrow_up_24, + ), + Receive( + text = resourceReference(R.string.common_receive), + iconRes = R.drawable.ic_arrow_down_24, + ), + Buy( + text = resourceReference(R.string.common_buy), + iconRes = R.drawable.ic_plus_24, + ), + Sell( + text = resourceReference(R.string.common_sell), + iconRes = R.drawable.ic_currency_24, + ), + Exchange( + text = resourceReference(R.string.common_exchange), + iconRes = R.drawable.ic_exchange_horizontal_24, + ), + Stake( + text = resourceReference(R.string.common_stake), + iconRes = R.drawable.ic_staking_24, + ), + YieldMode( + text = resourceReference(R.string.yield_module_start_earning), + iconRes = R.drawable.ic_analytics_up_mini_24, + ), + ; + + val order: Int = ordinal + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt new file mode 100644 index 0000000000..e93a65ddcd --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/WalletSelectorBSContentUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state + +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import kotlinx.collections.immutable.ImmutableList + +internal data class WalletSelectorBSContentUM( + val userWallets: ImmutableList, + val onBack: () -> Unit, +) : TangemBottomSheetConfigContent \ 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 966fedb289..91d996e6ea 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 @@ -16,8 +16,8 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.feed.state.* import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.* import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch import org.joda.time.DateTime import org.joda.time.DateTimeZone import javax.inject.Inject +import kotlin.collections.all @Stable @ModelScoped @@ -68,6 +69,11 @@ internal class FeedComponentModel @Inject constructor( TrendingNewsStateFactory( currentStateProvider = Provider { state.value }, onStateUpdate = { newState -> state.update { newState } }, + onRetryClicked = { + modelScope.launch(dispatchers.default) { + fetchTrendingNewsUseCase.invoke() + } + }, ) } @@ -88,14 +94,7 @@ internal class FeedComponentModel @Inject constructor( flow4 = manageTrendingNewsUseCase.observeTrendingNews(), ) { itemsByOrder, loadingStatesByOrder, errorStatesByOrder, trendingNewsResult -> updateMarketCharts(itemsByOrder, loadingStatesByOrder, errorStatesByOrder) - trendingNewsStateFactory.updateTrendingNewsState( - result = trendingNewsResult, - onRetryClicked = { - modelScope.launch(dispatchers.default) { - fetchTrendingNewsUseCase.invoke() - } - }, - ) + trendingNewsStateFactory.updateTrendingNewsState(result = trendingNewsResult) updateGlobalState() val currentSortType = state.value.marketChartConfig.currentSortByType val items = itemsByOrder[currentSortType] @@ -140,7 +139,11 @@ internal class FeedComponentModel @Inject constructor( onMarketItemClick = {}, onSortTypeClick = {}, ), - news = NewsUM.Loading, + news = NewsUM( + content = persistentListOf(), + onRetryClicked = {}, + newsUMState = NewsUMState.LOADING, + ), trendingArticle = null, marketChartConfig = MarketChartConfig( marketCharts = buildMap { @@ -224,10 +227,10 @@ internal class FeedComponentModel @Inject constructor( val newsState = currentState.news val marketCharts = currentState.marketChartConfig.marketCharts - val isNewsLoading = newsState is NewsUM.Loading + val isNewsLoading = newsState.newsUMState == NewsUMState.LOADING val areAllChartsLoading = marketCharts.values.all { it is MarketChartUM.Loading } - val isNewsError = newsState is NewsUM.Error + val isNewsError = newsState.newsUMState == NewsUMState.ERROR val areAllChartsError = marketCharts.values.all { it is MarketChartUM.LoadingError } val newGlobalState = when { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 08e876be53..5f5943a741 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -29,6 +29,8 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.markets.* import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.news.model.NewsListConfig +import com.tangem.domain.news.usecase.GetNewsUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions @@ -38,6 +40,7 @@ import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.converter.DescriptionConverter import com.tangem.features.feed.model.market.details.converter.ExchangeItemStateConverter +import com.tangem.features.feed.model.market.details.converter.RelatedNewsConverter import com.tangem.features.feed.model.market.details.converter.TokenMarketInfoConverter import com.tangem.features.feed.model.market.details.formatter.* import com.tangem.features.feed.model.market.details.state.QuotesStateUpdater @@ -49,6 +52,7 @@ 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.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -76,6 +80,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val getUserWalletsUseCase: GetWalletsUseCase, private val excludedBlockchains: ExcludedBlockchains, private val urlOpener: UrlOpener, + private val getNewsUseCase: GetNewsUseCase, ) : Model() { private val quotesJob = JobHolder() @@ -138,6 +143,10 @@ internal class MarketsTokenDetailsModel @Inject constructor( // ================== ) + private val relatedNewsConverter by lazy { + RelatedNewsConverter() + } + private val descriptionConverter = DescriptionConverter( onReadModeClicked = { content -> showBottomSheet(content) @@ -230,6 +239,12 @@ internal class MarketsTokenDetailsModel @Inject constructor( ), shouldShowPriceSubtitle = false, onShouldShowPriceSubtitleChange = ::onShouldShowPriceSubtitleChange, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = { + // TODO in [REDACTED_JIRA] + }, + ), ), ) @@ -264,6 +279,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( } initialLoad() + loadRelatedNews() } private fun initialLoad() { @@ -286,6 +302,27 @@ internal class MarketsTokenDetailsModel @Inject constructor( } } + private fun loadRelatedNews() { + modelScope.launch(dispatchers.default) { + getNewsUseCase.getNews( + limit = RELATED_NEWS_LIMIT, + newsListConfig = NewsListConfig( + language = Locale.getDefault().language, + snapshot = null, + tokenIds = listOf(params.token.id.value), + ), + ).onRight { articles -> + state.update { marketsTokenDetailsUM -> + marketsTokenDetailsUM.copy( + relatedNews = marketsTokenDetailsUM.relatedNews.copy( + articles = relatedNewsConverter.convert(articles), + ), + ) + } + } + } + } + private fun loadChart(interval: PriceChangeInterval) { modelScope.launch { state.update { marketsTokenDetailsUM -> @@ -649,5 +686,6 @@ internal class MarketsTokenDetailsModel @Inject constructor( private companion object { const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L + const val RELATED_NEWS_LIMIT = 10 } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt new file mode 100644 index 0000000000..4a9b183de1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/converter/RelatedNewsConverter.kt @@ -0,0 +1,84 @@ +package com.tangem.features.feed.model.market.details.converter + +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.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet +import org.joda.time.DateTime + +class RelatedNewsConverter : Converter, ImmutableList> { + + override fun convert(value: List): ImmutableList { + return value.map { shortArticle -> + ArticleConfigUM( + id = shortArticle.id, + title = shortArticle.title, + score = shortArticle.score, + isTrending = false, + tags = buildArticleTags(shortArticle), + createdAt = mapFormattedDate(shortArticle.createdAt), + isViewed = shortArticle.viewed, + ) + }.toPersistentList() + } + + private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet { + val categoryLabels = article.categories.map { category -> + LabelUM(text = TextReference.Str(category.name)) + } + val tokenLabels = article.relatedTokens.map { token -> + LabelUM( + text = TextReference.Str(token.symbol), + leadingContent = LabelLeadingContentUM.Token( + iconUrl = getTokenIconUrlFromDefaultHost( + tokenId = CryptoCurrency.RawID(token.id), + ), + ), + ) + } + return (categoryLabels + tokenLabels).toPersistentSet() + } + + 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/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index 19e70cf231..323fe1b119 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -238,7 +238,7 @@ internal class MarketsListModel @Inject constructor( modelScope.launch { marketsListUMStateManager.isInSearchStateFlow.collectLatest { isInSearchMode -> - activeListManager = if (isInSearchMode) { + activeListManager = if (isInSearchMode || marketsListUMStateManager.searchQuery.isNotEmpty()) { searchMarketsListManager } else { searchMarketsListManager.clearStateAndStopAllActions() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt index 950630f411..0aaf292083 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -1,9 +1,5 @@ package com.tangem.features.feed.ui -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Scaffold @@ -39,8 +35,7 @@ internal fun EntryContent( containerColor = background, contentWindowInsets = WindowInsetsZero, topBar = { - AnimatedContent( - targetState = stackState.value.active.instance, + Children( modifier = Modifier .then( if (!isOpenedInBottomSheet) { @@ -56,8 +51,11 @@ internal fun EntryContent( } } }, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - ) { currentState -> currentState.Title(bottomSheetState) } + stack = stackState.value, + animation = stackAnimation(fade()), + ) { child -> + child.instance.Title(bottomSheetState) + } }, content = { contentPadding -> Children( 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 e1362d244a..d2822c4ba1 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 @@ -49,9 +49,9 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState import com.tangem.features.feed.ui.feed.state.* -import com.tangem.features.feed.model.market.list.state.SortByTypeUM @Composable internal fun FeedListHeader(feedListSearchBar: FeedListSearchBar, modifier: Modifier = Modifier) { @@ -269,34 +269,26 @@ private fun MarketPulseBlock(marketChartConfig: MarketChartConfig, feedListCallb @Suppress("CanBeNonNullable") @Composable private fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { - AnimatedContent(news) { newsUM -> - when (newsUM) { - is NewsUM.Content -> { - if (newsUM.content.isNotEmpty()) { + AnimatedContent(news.newsUMState) { newsUMState -> + when (newsUMState) { + NewsUMState.LOADING -> NewsLoadingBlock() + NewsUMState.CONTENT -> { + if (news.content.isNotEmpty()) { NewsContentBlock( feedListCallbacks = feedListCallbacks, - news = newsUM, + news = news, trendingArticle = trendingArticle, ) } } - NewsUM.Loading -> { - NewsLoadingBlock() - } - is NewsUM.Error -> { - NewsErrorBlock(onRetryClick = newsUM.onRetryClicked) - } + NewsUMState.ERROR -> NewsErrorBlock(onRetryClick = news.onRetryClicked) } } } @Suppress("LongMethod") @Composable -private fun NewsContentBlock( - feedListCallbacks: FeedListCallbacks, - news: NewsUM.Content, - trendingArticle: ArticleConfigUM?, -) { +private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { Column { Header( title = { 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 8b084d229a..565f19a7bf 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 @@ -9,8 +9,8 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType 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.model.market.list.state.SortByTypeUM +import com.tangem.features.feed.ui.feed.state.* import kotlinx.collections.immutable.* @Suppress("MagicNumber") @@ -33,7 +33,11 @@ internal object FeedListPreviewDataProvider { onMarketItemClick = {}, onSortTypeClick = {}, ), - news = NewsUM.Content(articles.filter { it.isTrending.not() }.toImmutableList()), + news = NewsUM( + content = articles.filter { it.isTrending.not() }.toImmutableList(), + onRetryClicked = {}, + newsUMState = NewsUMState.CONTENT, + ), trendingArticle = articles.first { it.isTrending }, marketChartConfig = MarketChartConfig( marketCharts = createMarketCharts(marketItems, includeErrorState = 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 15e71f5a11..2602fb2395 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 @@ -33,11 +33,16 @@ internal data class FeedListSearchBar( val placeholderText: TextReference, ) -@Immutable -internal sealed interface NewsUM { - data object Loading : NewsUM - data class Content(val content: ImmutableList) : NewsUM - data class Error(val onRetryClicked: () -> Unit) : NewsUM +internal data class NewsUM( + val content: ImmutableList, + val onRetryClicked: () -> Unit, + val newsUMState: NewsUMState, +) + +internal enum class NewsUMState { + LOADING, + CONTENT, + ERROR, } internal data class MarketChartConfig( 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 15aee2ff6a..c2a568ad85 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 @@ -15,6 +15,8 @@ import com.tangem.domain.models.news.TrendingNews import com.tangem.features.feed.impl.R import com.tangem.utils.Provider import com.tangem.utils.StringsSigns +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentSet import org.joda.time.DateTime @@ -22,34 +24,48 @@ import org.joda.time.DateTime internal class TrendingNewsStateFactory( private val currentStateProvider: Provider, private val onStateUpdate: (FeedListUM) -> Unit, + private val onRetryClicked: () -> Unit, ) { - fun updateTrendingNewsState(result: TrendingNews, onRetryClicked: () -> Unit) { + fun updateTrendingNewsState(result: TrendingNews) { val currentState = currentStateProvider() when (result) { is TrendingNews.Data -> handleDataState(currentState, result.articles) - is TrendingNews.Error -> handleErrorState(currentState, onRetryClicked) + is TrendingNews.Error -> handleErrorState(currentState) } } private fun handleDataState(currentState: FeedListUM, articles: List) { val (trendingArticle, commonArticles) = separateTrendingAndCommonArticles(articles) + val commonArticlesUM = commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList() + val updatedNews = when (currentState.news.newsUMState) { + NewsUMState.CONTENT -> currentState.news.copy(content = commonArticlesUM) + NewsUMState.LOADING, + NewsUMState.ERROR, + -> NewsUM( + content = commonArticlesUM, + onRetryClicked = onRetryClicked, + newsUMState = NewsUMState.CONTENT, + ) + } onStateUpdate( currentState.copy( trendingArticle = trendingArticle?.let { mapToArticleConfigUM(it, isTrending = true) }, - news = NewsUM.Content( - commonArticles.map { mapToArticleConfigUM(it, isTrending = false) }.toPersistentList(), - ), + news = updatedNews, ), ) } - private fun handleErrorState(currentState: FeedListUM, onRetryClicked: () -> Unit) { + private fun handleErrorState(currentState: FeedListUM) { onStateUpdate( currentState.copy( trendingArticle = null, - news = NewsUM.Error(onRetryClicked = onRetryClicked), + news = NewsUM( + content = persistentListOf(), + onRetryClicked = onRetryClicked, + newsUMState = NewsUMState.ERROR, + ), ), ) } @@ -79,7 +95,7 @@ internal class TrendingNewsStateFactory( ) } - private fun buildArticleTags(article: ShortArticle): kotlinx.collections.immutable.ImmutableSet { + private fun buildArticleTags(article: ShortArticle): ImmutableSet { val categoryLabels = article.categories.map { category -> LabelUM(text = TextReference.Str(category.name)) } 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 4773bac517..60ea3e9f82 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 @@ -135,6 +135,7 @@ private fun Content( state = state.body, isAccountEnabled = isAccountEnabled, portfolioBlock = portfolioBlock, + relatedNews = state.relatedNews, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index 2bb53ddbc1..d36d027eaa 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -1,26 +1,33 @@ package com.tangem.features.feed.ui.market.detailed.components -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState 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.common.ui.news.ArticleCard +import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.items.DescriptionPlaceholder import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM +import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM.RelatedNews @Suppress("CanBeNonNullable") // TODO will be removed after [REDACTED_JIRA] internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, isAccountEnabled: Boolean, portfolioBlock: @Composable ((Modifier) -> Unit)?, + relatedNews: RelatedNews, ) { when (state) { MarketsTokenDetailsUM.Body.Loading -> { @@ -51,6 +58,10 @@ internal fun LazyListScope.tokenMarketDetailsBody( } } + if (relatedNews.articles.isNotEmpty()) { + relatedNews(relatedNews) + } + if (isAccountEnabled) { aboutCoinHeader() } @@ -190,6 +201,45 @@ private fun LazyListScope.loadingInfoBlocks() { } } +private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { + item("related-news") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp, top = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + modifier = Modifier.padding(start = 16.dp), + text = "Related news", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + + LazyRow( + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + state = rememberLazyListState(), + ) { + items( + items = relatedNews.articles, + key = ArticleConfigUM::id, + ) { article -> + ArticleCard( + articleConfigUM = article, + onArticleClick = { relatedNews.onArticledClicked(article.id) }, + modifier = Modifier + .height(164.dp) + .width(216.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), + ) + } + } + } + } +} + @Composable private fun Modifier.blockPaddings(): Modifier { return this.padding( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt index 8f44d04000..964ccd4dad 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/preview/MarketsTokenDetailsPreview.kt @@ -7,13 +7,7 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.features.feed.ui.market.detailed.state.InfoPointUM -import com.tangem.features.feed.ui.market.detailed.state.InsightsUM -import com.tangem.features.feed.ui.market.detailed.state.ListedOnUM -import com.tangem.features.feed.ui.market.detailed.state.MarketsTokenDetailsUM -import com.tangem.features.feed.ui.market.detailed.state.MetricsUM -import com.tangem.features.feed.ui.market.detailed.state.PricePerformanceUM -import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM +import com.tangem.features.feed.ui.market.detailed.state.* import kotlinx.collections.immutable.persistentListOf internal object MarketsTokenDetailsPreview { @@ -49,6 +43,10 @@ internal object MarketsTokenDetailsPreview { triggerPriceChange = consumedEvent(), onShouldShowPriceSubtitleChange = {}, shouldShowPriceSubtitle = false, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = {}, + ), ) val contentState = MarketsTokenDetailsUM( @@ -135,5 +133,9 @@ internal object MarketsTokenDetailsPreview { triggerPriceChange = consumedEvent(), onShouldShowPriceSubtitleChange = {}, shouldShowPriceSubtitle = false, + relatedNews = MarketsTokenDetailsUM.RelatedNews( + articles = persistentListOf(), + onArticledClicked = {}, + ), ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt index 86685f6ac9..a1155099a5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt @@ -2,11 +2,13 @@ package com.tangem.features.feed.ui.market.detailed.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.news.ArticleConfigUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.markets.PriceChangeInterval +import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal internal data class MarketsTokenDetailsUM( @@ -25,6 +27,7 @@ internal data class MarketsTokenDetailsUM( val body: Body, val shouldShowPriceSubtitle: Boolean, val onShouldShowPriceSubtitleChange: (Boolean) -> Unit, + val relatedNews: RelatedNews, ) { data class ChartState( @@ -69,4 +72,9 @@ internal data class MarketsTokenDetailsUM( val fullDescription: TextReference?, val onReadMoreClick: () -> Unit, ) + + data class RelatedNews( + val articles: ImmutableList, + val onArticledClicked: (id: Int) -> Unit, + ) } \ No newline at end of file