diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 7666729a36..4889626ff1 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -5,8 +5,10 @@ import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.markets.* import com.tangem.domain.markets.repositories.MarketsTokenRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.promo.PromoRepository import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.quotes.single.SingleQuoteSupplier +import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles @@ -109,4 +111,18 @@ object MarketsDomainModule { fun provideGetTokenExchangesUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenExchangesUseCase { return GetTokenExchangesUseCase(marketsTokenRepository = marketsTokenRepository) } + + @Provides + @Singleton + fun provideGetStakingNotificationMaxApyUseCase( + settingsRepository: SettingsRepository, + promoRepository: PromoRepository, + marketsTokenRepository: MarketsTokenRepository, + ): GetStakingNotificationMaxApyUseCase { + return GetStakingNotificationMaxApyUseCase( + settingsRepository = settingsRepository, + promoRepository = promoRepository, + marketsTokenRepository = marketsTokenRepository, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt index 7dc5634128..df25dd1255 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt @@ -226,5 +226,13 @@ internal object SettingsDomainModule { fun provideIsGooglePayAvailableUseCase(settingsRepository: SettingsRepository): IsGooglePayAvailableUseCase { return IsGooglePayAvailableUseCase(settingsRepository) } + + @Provides + @Singleton + fun provideMaybeSetWalletFirstTimeUsageUseCase( + settingsRepository: SettingsRepository, + ): SetWalletFirstTimeUsageUseCase { + return SetWalletFirstTimeUsageUseCase(settingsRepository) + } // endregion } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt b/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt index 91c45b832a..a3b184dc8e 100644 --- a/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt +++ b/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt @@ -49,12 +49,12 @@ internal class HomeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val scanCardProcessor: ScanCardProcessor, private val saveWalletUseCase: SaveWalletUseCase, - private val getUserCountryUseCase: GetUserCountryUseCase, private val cardSdkConfigRepository: CardSdkConfigRepository, private val settingsRepository: SettingsRepository, private val urlOpener: UrlOpener, private val analyticsEventHandler: AnalyticsEventHandler, private val userWalletBuilderFactory: UserWalletBuilder.Factory, + getUserCountryUseCase: GetUserCountryUseCase, ) : Model() { private val tangemErrorHandler = TangemTangemErrorsHandler(store) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt index b2af512cda..3c1829900b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt @@ -12,6 +12,7 @@ data class TokenMarketListResponse( @Json(name = "limit") val limit: Int, @Json(name = "offset") val offset: Int, @Json(name = "timestamp") val timestamp: Long? = null, + @Json(name = "summary") val summary: Summary? = null, ) { @JsonClass(generateAdapter = true) @@ -24,6 +25,7 @@ data class TokenMarketListResponse( @Json(name = "market_rating") val marketRating: Int?, @Json(name = "market_cap") val marketCap: BigDecimal?, @Json(name = "is_under_market_cap_limit") val isUnderMarketCapLimit: Boolean?, + @Json(name = "staking_opportunities") val stakingOpportunities: List?, ) { @JsonClass(generateAdapter = true) @@ -32,5 +34,27 @@ data class TokenMarketListResponse( @Json(name = "1w") val week1: BigDecimal?, @Json(name = "30d") val day30: BigDecimal?, ) + + @JsonClass(generateAdapter = true) + data class StakingOpportunities( + @Json(name = "id") val id: Int?, + @Json(name = "apy") val apy: BigDecimal?, + @Json(name = "network_id") val networkId: String?, + @Json(name = "reward_type") val rewardType: RewardType?, + ) + + @JsonClass(generateAdapter = false) + enum class RewardType { + @Json(name = "apy") APY, + + @Json(name = "apr") APR, + + UNKNOWN, + } } + + @JsonClass(generateAdapter = true) + data class Summary( + @Json(name = "max_apy") val maxApy: BigDecimal?, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 90b07d055f..5e4f9577d1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -86,6 +86,12 @@ object PreferencesKeys { val SHOULD_SHOW_MARKETS_TOOLTIP_KEY by lazy { booleanPreferencesKey(name = "shouldShowMarketsTooltip") } + val MARKETS_STAKING_NOTIFICATION_HIDE_CLICKED_KEY by lazy { + booleanPreferencesKey(name = "marketsStakingNotificationHideClicked") + } + + val WALLET_FIRST_USAGE_DATE_KEY by lazy { longPreferencesKey(name = "walletFirstUsageDate") } + val IS_WALLET_NAMES_MIGRATION_DONE_KEY by lazy { booleanPreferencesKey(name = "isWalletNamesMigrationDone") } val UNSUBMITTED_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "unsubmittedTransactions") } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index 4050ff267f..a22a0c468c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -321,7 +321,7 @@ private fun SecondaryPairButtons( } @Composable -internal fun CloseableIconButton( +fun CloseableIconButton( onClick: (() -> Unit)?, modifier: Modifier = Modifier, isEnabled: Boolean = true, diff --git a/core/ui/src/main/res/drawable/img_staking_in_market_notification.webp b/core/ui/src/main/res/drawable/img_staking_in_market_notification.webp new file mode 100644 index 0000000000..de2b304a0f Binary files /dev/null and b/core/ui/src/main/res/drawable/img_staking_in_market_notification.webp differ diff --git a/data/markets/build.gradle.kts b/data/markets/build.gradle.kts index fa746e853f..dec987eadb 100644 --- a/data/markets/build.gradle.kts +++ b/data/markets/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { // endregion // region Others dependencies + implementation(deps.androidx.datastore) implementation(deps.kotlin.coroutines) implementation(deps.jodatime) implementation(deps.moshi) diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index 91c229e3ef..3eb641be45 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -27,7 +27,9 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.pagination.* import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +import java.math.BigDecimal import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong @@ -41,6 +43,7 @@ internal class DefaultMarketsTokenRepository( private val excludedBlockchains: ExcludedBlockchains, private val cacheRegistry: CacheRegistry, private val tokenExchangesStore: RuntimeStateStore>, + private val maxApyStore: RuntimeStateStore, ) : MarketsTokenRepository { private val tokenMarketInfoConverter: TokenMarketInfoConverter = TokenMarketInfoConverter(excludedBlockchains) @@ -88,8 +91,12 @@ internal class DefaultMarketsTokenRepository( val last = res.tokens.size < request.limit + val tokenMarketListWithMaxApy = TokenMarketListConverter.convert(res) + + maxApyStore.store(tokenMarketListWithMaxApy.maxApy) + return BatchFetchResult.Success( - data = TokenMarketListConverter.convert(res), + data = tokenMarketListWithMaxApy.tokens, last = last, empty = res.tokens.isEmpty(), ) @@ -275,6 +282,10 @@ internal class DefaultMarketsTokenRepository( } } + override suspend fun getMaxApy(): Flow { + return maxApyStore.get() + } + inline fun catchListErrorAndSendEvent(block: () -> T): T { return catchErrorAndSendEvent(block, ::createListErrorEvent) } diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt index 1875a5b609..e280e160e1 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt @@ -15,6 +15,7 @@ internal fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) TokenMarketListConfig.Order.Buyers -> "buyers" TokenMarketListConfig.Order.TopGainers -> "gainers" TokenMarketListConfig.Order.TopLosers -> "losers" + TokenMarketListConfig.Order.Staking -> "staking" } internal fun PriceChangeInterval.toRequestParam(): String = when (this) { diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt index 2091fcf399..da25a28d09 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt @@ -2,22 +2,29 @@ package com.tangem.data.markets.converters import com.tangem.datasource.api.markets.models.response.TokenMarketListResponse import com.tangem.domain.markets.TokenMarket +import com.tangem.domain.markets.TokenMarketListWithMaxApy import com.tangem.domain.markets.TokenQuotesShort import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isPositive -internal object TokenMarketListConverter : Converter> { +internal object TokenMarketListConverter : Converter { - override fun convert(value: TokenMarketListResponse): List { + override fun convert(value: TokenMarketListResponse): TokenMarketListWithMaxApy { val imageHost = value.imageHost ?: run { if (value.tokens.isEmpty()) { - return emptyList() + return TokenMarketListWithMaxApy(emptyList(), null) } else { error("imageHost cannot be null") } } - return value.tokens.map { token -> + val tokens = value.tokens.map { token -> + val stakingRate = token.stakingOpportunities + ?.mapNotNull { it.apy } + ?.max() + .takeIf { it?.isPositive() == true } + TokenMarket( id = CryptoCurrency.RawID(token.id), name = token.name, @@ -33,7 +40,9 @@ internal object TokenMarketListConverter : Converter { + return appPreferencesStore.get( + key = PreferencesKeys.MARKETS_STAKING_NOTIFICATION_HIDE_CLICKED_KEY, + default = false, + ) + } + + override suspend fun setMarketsStakingNotificationHideClicked() { + appPreferencesStore.store( + key = PreferencesKeys.MARKETS_STAKING_NOTIFICATION_HIDE_CLICKED_KEY, + value = true, + ) + } + override fun getStoryById(id: String): Flow = isReadyToShowStories(id).mapLatest { getStoryByIdSync(id = id, refresh = false) } diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt index 87ea3f6639..8bc077f7c7 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt @@ -96,6 +96,17 @@ internal class DefaultSettingsRepository( } } + override suspend fun getWalletFirstUsageDate(): Long { + return appPreferencesStore.getSyncOrDefault( + key = PreferencesKeys.WALLET_FIRST_USAGE_DATE_KEY, + default = 0L, + ) + } + + override suspend fun setWalletFirstUsageDate(value: Long) { + appPreferencesStore.store(key = PreferencesKeys.WALLET_FIRST_USAGE_DATE_KEY, value = value) + } + override suspend fun shouldShowMarketsTooltip(): Boolean { return appPreferencesStore.getSyncOrDefault( key = PreferencesKeys.SHOULD_SHOW_MARKETS_TOOLTIP_KEY, diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index 7d820cb19a..735657a25b 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -23,9 +23,11 @@ dependencies { api(projects.domain.quotes) api(projects.domain.wallets) api(projects.domain.wallets.models) + api(projects.domain.promo) implementation(projects.domain.tokens.models) implementation(projects.domain.tokens) + implementation(projects.domain.settings) api(projects.core.pagination) diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt index 1724d9ce45..d3c97c6943 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt @@ -12,6 +12,7 @@ data class TokenMarket( val isUnderMarketCapLimit: Boolean, val tokenQuotesShort: TokenQuotesShort, val tokenCharts: Charts, + val stakingRate: BigDecimal?, private val imageHost: String, ) { diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt index c3633f3925..011df2c630 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt @@ -8,7 +8,7 @@ data class TokenMarketListConfig( ) { enum class Order { - ByRating, Trending, Buyers, TopGainers, TopLosers + ByRating, Trending, Buyers, TopGainers, TopLosers, Staking } enum class Interval { diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListWithMaxApy.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListWithMaxApy.kt new file mode 100644 index 0000000000..3b3bc77831 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListWithMaxApy.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.markets + +import java.math.BigDecimal + +data class TokenMarketListWithMaxApy( + val tokens: List, + val maxApy: BigDecimal?, +) \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetStakingNotificationMaxApyUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetStakingNotificationMaxApyUseCase.kt new file mode 100644 index 0000000000..a14d6c7cbd --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetStakingNotificationMaxApyUseCase.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.markets + +import android.icu.util.Calendar +import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.settings.repositories.SettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import java.math.BigDecimal + +class GetStakingNotificationMaxApyUseCase( + private val settingsRepository: SettingsRepository, + private val promoRepository: PromoRepository, + private val marketsTokenRepository: MarketsTokenRepository, +) { + + suspend operator fun invoke(): Flow { + val hideClickedFlow = promoRepository.isMarketsStakingNotificationHideClicked() + val walletFirstUsageDate = settingsRepository.getWalletFirstUsageDate() + val currentDate = Calendar.getInstance().timeInMillis + + return combine( + flow = hideClickedFlow, + flow2 = marketsTokenRepository.getMaxApy(), + ) { hideClicked, maxApy -> + val showStakingNotification = if (!hideClicked && walletFirstUsageDate != 0L) { + currentDate - walletFirstUsageDate < TWO_WEEKS_IN_MILLIS + } else { + false + } + + maxApy.takeIf { showStakingNotification } + } + } + + private companion object { + const val TWO_WEEKS_IN_MILLIS = 14 * 24 * 60 * 60 * 1000L + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index 80a1a97480..c0fe937411 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -3,6 +3,8 @@ package com.tangem.domain.markets.repositories import com.tangem.domain.markets.* import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal interface MarketsTokenRepository { @@ -51,4 +53,6 @@ interface MarketsTokenRepository { * @param tokenId token id */ suspend fun getTokenExchanges(tokenId: CryptoCurrency.RawID): List + + suspend fun getMaxApy(): Flow } \ No newline at end of file diff --git a/domain/promo/src/main/java/com/tangem/domain/promo/PromoRepository.kt b/domain/promo/src/main/java/com/tangem/domain/promo/PromoRepository.kt index 88682deae2..bed593a152 100644 --- a/domain/promo/src/main/java/com/tangem/domain/promo/PromoRepository.kt +++ b/domain/promo/src/main/java/com/tangem/domain/promo/PromoRepository.kt @@ -15,6 +15,10 @@ interface PromoRepository { suspend fun setNeverToShowWalletPromo(promoId: PromoId) suspend fun setNeverToShowTokenPromo(promoId: PromoId) + + suspend fun isMarketsStakingNotificationHideClicked(): Flow + + suspend fun setMarketsStakingNotificationHideClicked() // endregion // region Stories diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/SetWalletFirstTimeUsageUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/SetWalletFirstTimeUsageUseCase.kt new file mode 100644 index 0000000000..c0cef31d77 --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/SetWalletFirstTimeUsageUseCase.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.settings + +import arrow.core.Either +import com.tangem.domain.settings.repositories.SettingsRepository +import java.util.Calendar + +class SetWalletFirstTimeUsageUseCase(private val settingsRepository: SettingsRepository) { + + suspend operator fun invoke() = Either.catch { + val savedTime = settingsRepository.getWalletFirstUsageDate() + + if (savedTime == 0L) { + settingsRepository.setWalletFirstUsageDate(Calendar.getInstance().timeInMillis) + } + } +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt index 23b4a1756c..1a64989894 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt @@ -30,6 +30,10 @@ interface SettingsRepository { suspend fun incrementAppLaunchCounter() + suspend fun getWalletFirstUsageDate(): Long + + suspend fun setWalletFirstUsageDate(value: Long) + suspend fun shouldShowMarketsTooltip(): Boolean suspend fun setMarketsTooltipShown(value: Boolean) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt index 99f0faa3c3..3395db941b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/analytics/MarketsListAnalyticsEvent.kt @@ -23,6 +23,7 @@ internal sealed class MarketsListAnalyticsEvent( SortByTypeUM.ExperiencedBuyers -> "Buyers" SortByTypeUM.TopGainers -> "Gainers" SortByTypeUM.TopLosers -> "Losers" + SortByTypeUM.Staking -> "Staking" }, "Period" to when (interval) { MarketsListUM.TrendInterval.H24 -> "24h" @@ -31,4 +32,10 @@ internal sealed class MarketsListAnalyticsEvent( }, ), ) + + data object StakingPromoShown : MarketsListAnalyticsEvent(event = "Notice - Staking Promo") + + data object StakingPromoClosed : MarketsListAnalyticsEvent(event = "Staking Promo Closed") + + data object StakingMoreInfoClicked : MarketsListAnalyticsEvent(event = "Staking More Info") } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt index 65622d2f0d..c3c73fed42 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.markets.tokenlist.impl.model import androidx.compose.runtime.Stable +import arrow.core.Either import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -8,7 +9,13 @@ import com.tangem.core.decompose.model.Model import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.GetStakingNotificationMaxApyUseCase import com.tangem.domain.markets.TokenMarket +import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.settings.usercountry.GetUserCountryUseCase +import com.tangem.domain.settings.usercountry.models.UserCountry +import com.tangem.domain.settings.usercountry.models.UserCountryError +import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.tokenlist.impl.analytics.MarketsListAnalyticsEvent @@ -21,8 +28,10 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import java.math.BigDecimal import javax.inject.Inject private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L @@ -31,23 +40,26 @@ private const val SEARCH_QUERY_DEBOUNCE_MILLIS = 800L @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @ModelScoped @Stable +@Suppress("LongParameterList") internal class MarketsListModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getStakingNotificationMaxApyUseCase: GetStakingNotificationMaxApyUseCase, + private val promoRepository: PromoRepository, + private val getUserCountryUseCase: GetUserCountryUseCase, private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private var updateQuotesJob = JobHolder() - private val currentAppCurrency = getSelectedAppCurrencyUseCase() - .map { maybeAppCurrency -> - maybeAppCurrency.getOrElse { AppCurrency.Default } - }.stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = AppCurrency.Default, - ) + private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) private val visibleItemIds = MutableStateFlow>(emptyList()) @@ -57,7 +69,10 @@ internal class MarketsListModel @Inject constructor( visibleItemsChanged = { visibleItemIds.value = it }, onRetryButtonClicked = { activeListManager.reload() }, onTokenClick = { onTokenUIClicked(it) }, + onStakingNotificationClick = { analyticsEventHandler.send(MarketsListAnalyticsEvent.StakingMoreInfoClicked) }, + onStakingNotificationCloseClick = { onStakingNotificationCloseClick() }, ) + private val mainMarketsListManager = MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, @@ -92,31 +107,60 @@ internal class MarketsListModel @Inject constructor( val state = marketsListUMStateManager.state.asStateFlow() init { - @Suppress("UnnecessaryParentheses") - modelScope.launch { - marketsListUMStateManager.isInSearchStateFlow - .flatMapLatest { isInSearchMode -> - if (isInSearchMode) { - combine( - searchMarketsListManager.uiItems, - searchMarketsListManager.isInInitialLoadingErrorState, - searchMarketsListManager.isSearchNotFoundState, - ) { items, isError, notFound -> - (items to isError) to notFound - } - } else { - combine( - mainMarketsListManager.uiItems, - mainMarketsListManager.isInInitialLoadingErrorState, - ) { items, isError -> (items to isError) to false } + @Suppress("UnnecessaryParentheses") modelScope.launch { + marketsListUMStateManager.isInSearchStateFlow.flatMapLatest { isInSearchMode -> + if (isInSearchMode) { + combine( + searchMarketsListManager.uiItems, + searchMarketsListManager.isInInitialLoadingErrorState, + searchMarketsListManager.isSearchNotFoundState, + getStakingNotificationMaxApyUseCase(), + getUserCountryUseCase.invoke(), + ) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, stakingMaxApy, userCountry -> + MarketsItemsData( + items = uiItems, + isInErrorState = isInInitialLoadingErrorState, + isSearchNotFound = isSearchNotFoundState, + stakingNotificationMaxApy = stakingMaxApy, + userCountry = userCountry, + ) + } + } else { + combine( + mainMarketsListManager.uiItems, + mainMarketsListManager.isInInitialLoadingErrorState, + getStakingNotificationMaxApyUseCase(), + getUserCountryUseCase.invoke(), + ) { uiItems, isInInitialLoadingErrorState, stakingNotificationMaxApy, userCountry -> + MarketsItemsData( + items = uiItems, + isInErrorState = isInInitialLoadingErrorState, + isSearchNotFound = false, + stakingNotificationMaxApy = stakingNotificationMaxApy, + userCountry = userCountry, + ) } - }.collect { - marketsListUMStateManager.onUiItemsChanged( - uiItems = it.first.first, - isInErrorState = it.first.second, - isSearchNotFound = it.second, - ) } + }.collect { marketsItemsData -> + val stakingNotificationMaxApy = marketsItemsData.stakingNotificationMaxApy?.takeUnless { + marketsItemsData.userCountry.getOrNull().needApplyFCARestrictions() + } + + if (marketsListUMStateManager.state.value.stakingNotificationMaxApy == null && + stakingNotificationMaxApy != null + ) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.StakingPromoShown) + } + + marketsListUMStateManager.onUiItemsChanged( + uiItems = marketsItemsData.items, + isInErrorState = marketsItemsData.isInErrorState, + isSearchNotFound = marketsItemsData.isSearchNotFound, + stakingNotificationMaxApy = marketsItemsData.stakingNotificationMaxApy?.takeUnless { + marketsItemsData.userCountry.getOrNull().needApplyFCARestrictions() + }, + ) + } } state.onEach { @@ -126,29 +170,22 @@ internal class MarketsListModel @Inject constructor( }.launchIn(modelScope) // update all lists when user's currency has changed - currentAppCurrency - .drop(1) - .onEach { - mainMarketsListManager.reload() - if (marketsListUMStateManager.isInSearchState) { - searchMarketsListManager.reload() - } - }.launchIn(modelScope) + currentAppCurrency.drop(1).onEach { + mainMarketsListManager.reload() + if (marketsListUMStateManager.isInSearchState) { + searchMarketsListManager.reload() + } + }.launchIn(modelScope) // load charts when new batch is being loaded - mainMarketsListManager.onLastBatchLoadedSuccess - .onEach { - mainMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) - modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) - } - .launchIn(modelScope) + mainMarketsListManager.onLastBatchLoadedSuccess.onEach { + mainMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) + modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) + }.launchIn(modelScope) // listen currently selected interval, update charts if sorting=rating, or reload all list modelScope.launch(dispatchers.default) { - marketsListUMStateManager.state - .map { it.selectedInterval } - .distinctUntilChanged() - .drop(1) + marketsListUMStateManager.state.map { it.selectedInterval }.distinctUntilChanged().drop(1) .collectLatest { interval -> when (marketsListUMStateManager.selectedSortByType) { SortByTypeUM.Rating -> { @@ -163,66 +200,52 @@ internal class MarketsListModel @Inject constructor( // reload list when sorting type has changed modelScope.launch { - marketsListUMStateManager.state - .map { it.selectedSortBy } - .distinctUntilChanged() - .drop(1) - .collectLatest { - mainMarketsListManager.reload() - } + marketsListUMStateManager.state.map { it.selectedSortBy }.distinctUntilChanged().drop(1).collectLatest { + mainMarketsListManager.reload() + } } // listen current visible batch and update charts modelScope.launch { - visibleItemIds - .mapNotNull { - if (it.isNotEmpty()) { - activeListManager.getBatchKeysByItemIds(visibleItemIds.value) - } else { - null - } - } - .distinctUntilChanged() - .collectLatest { visibleBatchKeys -> - // TODO load batch on scroll heat area - activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval) + visibleItemIds.mapNotNull { + if (it.isNotEmpty()) { + activeListManager.getBatchKeysByItemIds(visibleItemIds.value) + } else { + null } + }.distinctUntilChanged().collectLatest { visibleBatchKeys -> + // TODO load batch on scroll heat area + activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval) + } } // ===Search=== modelScope.launch { - marketsListUMStateManager.isInSearchStateFlow - .collectLatest { isInSearchMode -> - activeListManager = if (isInSearchMode) { - searchMarketsListManager - } else { - searchMarketsListManager.clearStateAndStopAllActions() - mainMarketsListManager - } + marketsListUMStateManager.isInSearchStateFlow.collectLatest { isInSearchMode -> + activeListManager = if (isInSearchMode) { + searchMarketsListManager + } else { + searchMarketsListManager.clearStateAndStopAllActions() + mainMarketsListManager } + } } modelScope.launch { - marketsListUMStateManager.searchQueryFlow - .debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS) - .distinctUntilChanged() - .onEach { + marketsListUMStateManager.searchQueryFlow.debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS) + .distinctUntilChanged().onEach { if (it.isEmpty()) searchMarketsListManager.clearStateAndStopAllActions() - } - .filter { it.isNotEmpty() && activeListManager == searchMarketsListManager } - .collectLatest { + }.filter { it.isNotEmpty() && activeListManager == searchMarketsListManager }.collectLatest { searchMarketsListManager.reload(searchText = it) } } modelScope.launch { - searchMarketsListManager - .onLastBatchLoadedSuccess - .collectLatest { - searchMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) - modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) - } + searchMarketsListManager.onLastBatchLoadedSuccess.collectLatest { + searchMarketsListManager.loadCharts(setOf(it), marketsListUMStateManager.selectedInterval) + modelScope.loadQuotesWithTimer(timeMillis = UPDATE_QUOTES_TIMER_MILLIS) + } } // analytics @@ -233,17 +256,14 @@ internal class MarketsListModel @Inject constructor( } private fun initAnalytics() { - containerBottomSheetState - .onEach { - if (it == BottomSheetState.EXPANDED) { - analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened) - } - }.launchIn(modelScope) + containerBottomSheetState.onEach { + if (it == BottomSheetState.EXPANDED) { + analyticsEventHandler.send(MarketsListAnalyticsEvent.BottomSheetOpened) + } + }.launchIn(modelScope) - state - .filter { it.isInSearchMode.not() } - .map { MarketsListAnalyticsEvent.SortBy(it.selectedSortBy, it.selectedInterval) } - .distinctUntilChanged() + state.filter { it.isInSearchMode.not() } + .map { MarketsListAnalyticsEvent.SortBy(it.selectedSortBy, it.selectedInterval) }.distinctUntilChanged() .onEach { analyticsEventHandler.send(it) }.launchIn(modelScope) @@ -270,4 +290,19 @@ internal class MarketsListModel @Inject constructor( } }.saveIn(updateQuotesJob) } + + private fun onStakingNotificationCloseClick() { + analyticsEventHandler.send(MarketsListAnalyticsEvent.StakingPromoClosed) + modelScope.launch { + promoRepository.setMarketsStakingNotificationHideClicked() + } + } + + private class MarketsItemsData( + val items: ImmutableList, + val isInErrorState: Boolean, + val isSearchNotFound: Boolean, + val stakingNotificationMaxApy: BigDecimal?, + val userCountry: Either, + ) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt index c4adb37979..d7352826b5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt @@ -5,6 +5,8 @@ import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.common.ui.charts.state.sorted import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.compact import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -12,6 +14,7 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.impl.R import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval import com.tangem.utils.converter.Converter @@ -37,8 +40,11 @@ internal class MarketsTokenItemConverter( price = value.getCurrentPrice(), trendPercentText = value.getTrendPercent(), trendType = value.getTrendType(), - chardData = value.getChartData(), + chartData = value.getChartData(), isUnder100kMarketCap = value.isUnderMarketCapLimit, + stakingRate = value.stakingRate?.format { percent() }?.let { + resourceReference(R.string.markets_apy_placeholder, wrappedList(it)) + }, ) } @@ -64,7 +70,7 @@ internal class MarketsTokenItemConverter( prevUI.trendPercentText, ) { new.getTrendPercent() }, trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() }, - chardData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chardData) { new.getChartData() }, + chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() }, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt index e178484e78..3cae93db38 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt @@ -323,6 +323,7 @@ internal class MarketsListBatchFlowManager( SortByTypeUM.ExperiencedBuyers -> TokenMarketListConfig.Order.Buyers SortByTypeUM.TopGainers -> TokenMarketListConfig.Order.TopGainers SortByTypeUM.TopLosers -> TokenMarketListConfig.Order.TopLosers + SortByTypeUM.Staking -> TokenMarketListConfig.Order.Staking } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt index aa7f006d19..ac022dcd2a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt @@ -16,14 +16,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import java.math.BigDecimal @Stable +@Suppress("LongParameterList") internal class MarketsListUMStateManager( private val currentVisibleIds: Provider>, private val onLoadMoreUiItems: () -> Unit, private val visibleItemsChanged: (itemsKeys: List) -> Unit, private val onRetryButtonClicked: () -> Unit, private val onTokenClick: (MarketsListItemUM) -> Unit, + private val onStakingNotificationClick: () -> Unit, + private val onStakingNotificationCloseClick: () -> Unit, ) { private var sortByBottomSheetIsShown @@ -87,6 +91,7 @@ internal class MarketsListUMStateManager( isInErrorState: Boolean, isSearchNotFound: Boolean, uiItems: ImmutableList, + stakingNotificationMaxApy: BigDecimal?, ) { state.update { when { @@ -102,13 +107,19 @@ internal class MarketsListUMStateManager( it.copy(list = ListUM.Loading) } else -> { - it.updateItems(newItems = uiItems) + it.updateItems( + newItems = uiItems, + stakingNotificationMaxApy = stakingNotificationMaxApy, + ) } } } } - private fun MarketsListUM.updateItems(newItems: ImmutableList): MarketsListUM { + private fun MarketsListUM.updateItems( + newItems: ImmutableList, + stakingNotificationMaxApy: BigDecimal?, + ): MarketsListUM { val currentState = this if (isInSearchMode.not() || currentState.showUnder100kButtonAlreadyPressed()) { @@ -119,6 +130,7 @@ internal class MarketsListUMStateManager( .copy( showUnder100kTokensNotificationWasHidden = currentState.showUnder100kButtonAlreadyPressed(), ), + stakingNotificationMaxApy = stakingNotificationMaxApy, ) } @@ -211,6 +223,12 @@ internal class MarketsListUMStateManager( onOptionClicked = ::onBottomSheetOptionClicked, ), ), + stakingNotificationMaxApy = null, + onStakingNotificationClick = { + onStakingNotificationClick() + selectedSortByType = SortByTypeUM.Staking + }, + onStakingNotificationCloseClick = onStakingNotificationCloseClick, ) private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt index e1b6bfd37b..1442d9643f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.buildAnnotatedString import com.tangem.core.ui.components.Keyboard import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.SpacerH8 @@ -29,10 +30,11 @@ import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.event.consumedEvent -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.extensions.* +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -41,6 +43,7 @@ import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.impl.R import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListLazyColumn import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListSortByBottomSheet +import com.tangem.features.markets.tokenlist.impl.ui.components.StakingInMarketsPromoNotification import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM @@ -48,6 +51,9 @@ import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetCont import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +private const val SHOW_MORE_KEY = "privacyPolicy" @Composable internal fun MarketsList( @@ -110,14 +116,50 @@ private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modi SpacerH12() } } - AnimatedVisibility(state.isInSearchMode.not()) { - Options( - modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), - sortByTypeUM = state.selectedSortBy, - trendInterval = state.selectedInterval, - onIntervalClick = state.onIntervalClick, - onSortByClick = state.onSortByButtonClick, - ) + Column { + AnimatedVisibility(state.isInSearchMode.not()) { + Options( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + sortByTypeUM = state.selectedSortBy, + trendInterval = state.selectedInterval, + onIntervalClick = state.onIntervalClick, + onSortByClick = state.onSortByButtonClick, + ) + } + + AnimatedVisibility( + state.isInSearchMode.not() && + state.stakingNotificationMaxApy != null && + state.selectedSortBy != SortByTypeUM.Staking, + ) { + val showMore = stringResourceSafe(R.string.markets_staking_banner_description_show_more) + val description = stringResourceSafe( + R.string.markets_staking_banner_description_placeholder, + showMore, + ) + + val clickableDescription = buildAnnotatedString { + append(description.substringBefore(showMore)) + + pushStringAnnotation(SHOW_MORE_KEY, "") + appendColored(showMore, TangemTheme.colors.text.accent) + pop() + } + + StakingInMarketsPromoNotification( + config = NotificationConfig( + iconResId = R.drawable.img_staking_in_market_notification, + title = resourceReference( + R.string.markets_staking_banner_title, + wrappedList(state.stakingNotificationMaxApy.format { percent() }), + ), + subtitle = annotatedReference(clickableDescription), + onClick = state.onStakingNotificationClick, + onCloseClick = state.onStakingNotificationCloseClick, + ), + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + ) + } } } val strokeWidth = TangemTheme.dimens.size0_5 @@ -326,6 +368,9 @@ private fun Preview() { onDismissRequest = {}, content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, ), + stakingNotificationMaxApy = BigDecimal(0.12345), + onStakingNotificationClick = {}, + onStakingNotificationCloseClick = {}, ), onHeaderSizeChange = {}, bottomSheetState = BottomSheetState.EXPANDED, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt index c153024da0..4c8ed29713 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt @@ -2,6 +2,7 @@ package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.Button @@ -22,6 +23,8 @@ import com.tangem.core.ui.components.* import com.tangem.core.ui.components.currency.icon.CoinIcon import com.tangem.core.ui.components.marketprice.PriceChangeInPercent import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -95,6 +98,7 @@ private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier .alignByBaseline(), ratingPosition = model.ratingPosition, marketCap = model.marketCap, + stakingRate = model.stakingRate, ) PriceChangeInPercent( modifier = Modifier.alignByBaseline(), @@ -110,7 +114,7 @@ private fun MarketsListItemContent(model: MarketsListItemUM, modifier: Modifier Chart( chartType = model.chartType, - chartRawData = model.chardData, + chartRawData = model.chartData, ) } } @@ -142,7 +146,12 @@ private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier } @Composable -private fun TokenSubtitle(ratingPosition: String?, marketCap: String?, modifier: Modifier = Modifier) { +private fun TokenSubtitle( + ratingPosition: String?, + marketCap: String?, + stakingRate: TextReference?, + modifier: Modifier = Modifier, +) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, @@ -150,6 +159,10 @@ private fun TokenSubtitle(ratingPosition: String?, marketCap: String?, modifier: TokenRatingPlace(ratingPosition = ratingPosition) SpacerW4() TokenMarketCapText(text = marketCap ?: "") + if (stakingRate != null) { + SpacerW4() + StakingRate(stakingRate = stakingRate.resolveReference()) + } } } @@ -174,6 +187,28 @@ private fun RowScope.TokenRatingPlace(ratingPosition: String?) { } } +@Composable +private fun RowScope.StakingRate(stakingRate: String) { + Box( + modifier = Modifier + .alignByBaseline() + .heightIn(min = TangemTheme.dimens.size16) + .border( + width = TangemTheme.dimens.size1, + color = TangemTheme.colors.field.primary, + shape = TangemTheme.shapes.roundedCornersSmall2, + ) + .padding(horizontal = TangemTheme.dimens.spacing5), + ) { + Text( + text = stakingRate, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption1, + maxLines = 1, + ) + } +} + @Composable private fun RowScope.TokenMarketCapText(text: String) { Text( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/StakingInMarketsPromoNotification.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/StakingInMarketsPromoNotification.kt new file mode 100644 index 0000000000..9026a0ebb4 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/StakingInMarketsPromoNotification.kt @@ -0,0 +1,147 @@ +package com.tangem.features.markets.tokenlist.impl.ui.components + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.notifications.CloseableIconButton +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +private val bgColor = Color(0x1F8CD9FF) +private val borderColor = Color(0x3D8CD9FF) + +@Composable +fun StakingInMarketsPromoNotification(config: NotificationConfig, modifier: Modifier = Modifier) { + var textHeightDp by remember { mutableStateOf(0.dp) } + + Box( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + shape = TangemTheme.shapes.roundedCornersXMedium, + color = borderColor, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .background(bgColor) + .clickable { config.onClick?.invoke() }, + ) { + PromoImage( + iconRes = config.iconResId, + modifier = Modifier.height(textHeightDp), + ) + PromoText( + title = config.title, + subtitle = config.subtitle, + onSizeChange = { textHeightDp = it }, + ) + CloseableIconButton( + onClick = config.onCloseClick, + modifier = Modifier.align(alignment = Alignment.TopEnd), + iconTint = TangemTheme.colors.icon.secondary, + ) + } +} + +@Composable +private fun PromoImage(@DrawableRes iconRes: Int, modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .requiredWidth(56.dp) + .wrapContentHeight(Alignment.Top, unbounded = true), + ) + } +} + +@Composable +private fun PromoText(title: TextReference?, subtitle: TextReference, onSizeChange: (Dp) -> Unit) { + val density = LocalDensity.current + + Box( + modifier = Modifier.onSizeChanged { + with(density) { onSizeChange(it.height.toDp()) } + }, + ) { + TextsBlock( + title = title, + subtitle = subtitle, + modifier = Modifier + .wrapContentHeight() + .align(Alignment.CenterStart) + .padding(start = 76.dp, top = 12.dp, end = 12.dp, bottom = 12.dp), + ) + } +} + +@Composable +private fun TextsBlock(title: TextReference?, subtitle: TextReference, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val titleText = title?.resolveReference() + + if (titleText != null) { + Text( + text = titleText, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.button, + ) + + SpacerH(height = TangemTheme.dimens.spacing2) + } + + Text( + text = subtitle.resolveAnnotatedReference(), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.caption2, + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun RingPromoNotification_Preview() { + TangemThemePreview { + StakingInMarketsPromoNotification( + config = NotificationConfig( + title = stringReference("Earn up to 14% APY"), + subtitle = stringReference("Staking is the easiest way to earn rewards on your crypto. Show more"), + iconResId = R.drawable.img_staking_in_market_notification, + onCloseClick = { }, + ), + ) + } +} + +// endregion \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt index 97eecee6d1..464335eb3b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt @@ -4,6 +4,7 @@ package com.tangem.features.markets.tokenlist.impl.ui.preview import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM import kotlinx.collections.immutable.persistentListOf @@ -20,10 +21,11 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet price = MarketsListItemUM.Price(text = "31 285.72$"), trendPercentText = "12.43%", trendType = PriceChangeType.UP, - chardData = MarketChartRawData( + chartData = MarketChartRawData( y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), ), MarketsListItemUM( id = CryptoCurrency.RawID("1"), @@ -35,8 +37,9 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet price = MarketsListItemUM.Price(text = "31 285.72$"), trendPercentText = "12.43%", trendType = PriceChangeType.NEUTRAL, - chardData = null, + chartData = null, isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), ), MarketsListItemUM( id = CryptoCurrency.RawID("1"), @@ -48,10 +51,11 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet price = MarketsListItemUM.Price(text = "31 285.72$"), trendPercentText = "12.43%", trendType = PriceChangeType.DOWN, - chardData = MarketChartRawData( + chartData = MarketChartRawData( y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), ), MarketsListItemUM( id = CryptoCurrency.RawID("1"), @@ -63,10 +67,11 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet price = MarketsListItemUM.Price(text = "31 285.72$"), trendPercentText = "12.43%", trendType = PriceChangeType.UP, - chardData = MarketChartRawData( + chartData = MarketChartRawData( y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), ), MarketsListItemUM( id = CryptoCurrency.RawID("1"), @@ -78,10 +83,11 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet price = MarketsListItemUM.Price(text = "31 285.72$"), trendPercentText = "12.43%", trendType = PriceChangeType.UP, - chardData = MarketChartRawData( + chartData = MarketChartRawData( y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), ), MarketsListItemUM( id = CryptoCurrency.RawID("1"), @@ -93,10 +99,11 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet price = MarketsListItemUM.Price(text = "31 285.72$"), trendPercentText = "12.43%", trendType = PriceChangeType.UP, - chardData = MarketChartRawData( + chartData = MarketChartRawData( y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), ), ), ) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt index 458a1e29a3..2cb553987a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.tokens.model.CryptoCurrency @Immutable @@ -17,8 +18,9 @@ data class MarketsListItemUM( val price: Price, val trendPercentText: String, val trendType: PriceChangeType, - val chardData: MarketChartRawData?, + val chartData: MarketChartRawData?, val isUnder100kMarketCap: Boolean, + val stakingRate: TextReference?, ) { val chartType: MarketChartLook.Type = when (trendType) { PriceChangeType.UP -> MarketChartLook.Type.Growing diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt index 298f90f22f..2e99a45aec 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.features.markets.impl.R import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal internal data class MarketsListUM( val list: ListUM, @@ -18,6 +19,9 @@ internal data class MarketsListUM( val selectedInterval: TrendInterval, val onIntervalClick: (TrendInterval) -> Unit, val onSortByButtonClick: () -> Unit, + val stakingNotificationMaxApy: BigDecimal?, + val onStakingNotificationClick: () -> Unit, + val onStakingNotificationCloseClick: () -> Unit, ) { val isInSearchMode get() = searchBar.isActive @@ -35,6 +39,7 @@ enum class SortByTypeUM(val text: TextReference) { ExperiencedBuyers(resourceReference(R.string.markets_sort_by_experienced_buyers_title)), TopGainers(resourceReference(R.string.markets_sort_by_top_gainers_title)), TopLosers(resourceReference(R.string.markets_sort_by_top_losers_title)), + Staking(resourceReference(R.string.common_staking)), } @Immutable diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 4922cf66c1..b06d42aa58 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -67,6 +67,7 @@ internal class WalletModel @Inject constructor( private val getWalletsUseCase: GetWalletsUseCase, private val shouldShowSaveWalletScreenUseCase: ShouldShowSaveWalletScreenUseCase, private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase, + private val setWalletFirstTimeUsageUseCase: SetWalletFirstTimeUsageUseCase, private val canUseBiometryUseCase: CanUseBiometryUseCase, private val isWalletsScrollPreviewEnabled: IsWalletsScrollPreviewEnabled, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, @@ -103,6 +104,7 @@ internal class WalletModel @Inject constructor( suggestToOpenMarkets() maybeMigrateNames() + maybeSetWalletFirstTimeUsage() subscribeToUserWalletsUpdates() subscribeOnBalanceHiding() subscribeOnSelectedWalletFlow() @@ -117,6 +119,12 @@ internal class WalletModel @Inject constructor( } } + private fun maybeSetWalletFirstTimeUsage() { + modelScope.launch { + setWalletFirstTimeUsageUseCase() + } + } + override fun onDestroy() { super.onDestroy()