Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-17 17:42:20 +03:00
commit 3da7b93e8a
36 changed files with 371 additions and 173 deletions

View file

@ -36,6 +36,7 @@ class CardContextInterceptor(
is SignIn.ScreenOpened,
is SignIn.ButtonAddWallet,
-> false
is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll
else -> true
}
}

View file

@ -19,6 +19,7 @@ class HotWalletContextInterceptor(
is SignIn.ButtonUnlockAllWithBiometric,
is IntroductionProcess.ButtonScanCard,
-> false
is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll
else -> true
}
}

View file

@ -8,7 +8,6 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.tokens.repository.CurrenciesRepository
@ -129,13 +128,11 @@ object MarketsDomainModule {
@Provides
@Singleton
fun provideGetStakingNotificationMaxApyUseCase(
settingsRepository: SettingsRepository,
fun provideShouldShowYieldModeMarketPromoUseCase(
promoRepository: PromoRepository,
marketsTokenRepository: MarketsTokenRepository,
): GetStakingNotificationMaxApyUseCase {
return GetStakingNotificationMaxApyUseCase(
settingsRepository = settingsRepository,
): ShouldShowYieldModeMarketPromoUseCase {
return ShouldShowYieldModeMarketPromoUseCase(
promoRepository = promoRepository,
marketsTokenRepository = marketsTokenRepository,
)

View file

@ -16,6 +16,7 @@ inline fun UnlockWalletError.handle(
onAlreadyUnlocked: () -> Unit = {},
onUserCancelled: () -> Unit = {},
analyticsEventHandler: AnalyticsEventHandler,
isFromUnlockAll: Boolean,
noinline showMessage: (EventMessage) -> Unit,
) {
when (this) {
@ -33,11 +34,17 @@ inline fun UnlockWalletError.handle(
// This should never happen in this flow, as we always check for the wallet existence before unlocking
showMessage(SnackbarMessage(TextReference.Res(R.string.generic_error)))
}
is UnlockWalletError.UnableToUnlock -> handleUnableToUnlock(this, analyticsEventHandler, showMessage)
is UnlockWalletError.UnableToUnlock -> handleUnableToUnlock(
isFromUnlockAll = isFromUnlockAll,
error = this,
analyticsEventHandler = analyticsEventHandler,
showDialog = showMessage,
)
}
}
fun handleUnableToUnlock(
isFromUnlockAll: Boolean,
error: UnlockWalletError.UnableToUnlock,
analyticsEventHandler: AnalyticsEventHandler,
showDialog: (DialogMessage) -> Unit,
@ -46,7 +53,7 @@ fun handleUnableToUnlock(
is UnlockWalletError.UnableToUnlock.WithReason -> {
when (error.reason) {
Reason.AllKeysInvalidated -> {
analyticsEventHandler.send(SignIn.ErrorBiometricUpdated())
analyticsEventHandler.send(SignIn.ErrorBiometricUpdated(isFromUnlockAll))
DialogMessage(
title = resourceReference(R.string.biometric_updated_warning_title),
message = resourceReference(R.string.biometric_updated_warning_description),

View file

@ -21,7 +21,9 @@ sealed class SignIn(
class ButtonUnlockAllWithBiometric : SignIn(event = "Button - Unlock All With Biometric")
class ErrorBiometricUpdated : SignIn(event = "Error - Biometric Updated")
data class ErrorBiometricUpdated(
val isFromUnlockAll: Boolean,
) : SignIn(event = "Error - Biometric Updated")
class ButtonWallet(
signInType: SignInType,

View file

@ -26,6 +26,7 @@ data class TokenMarketListResponse(
@Json(name = "market_cap") val marketCap: BigDecimal?,
@Json(name = "is_under_market_cap_limit") val isUnderMarketCapLimit: Boolean?,
@Json(name = "staking_opportunities") val stakingOpportunities: List<StakingOpportunities>?,
@Json(name = "max_yield_apy") val maxYieldApy: BigDecimal?,
) {
@JsonClass(generateAdapter = true)

View file

@ -77,8 +77,8 @@ 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 MARKETS_YIELD_SUPPLY_NOTIFICATION_HIDE_CLICKED_KEY by lazy {
booleanPreferencesKey(name = "marketsYieldSupplyNotificationHideClicked")
}
val WALLET_FIRST_USAGE_DATE_KEY by lazy { longPreferencesKey(name = "walletFirstUsageDate") }

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -20,6 +20,7 @@ import com.tangem.datasource.api.markets.TangemTechMarketsApi
import com.tangem.datasource.api.markets.models.response.TokenMarketExchangesResponse
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.domain.markets.repositories.MarketsTokenRepository
import com.tangem.domain.models.account.DerivationIndex
@ -28,9 +29,8 @@ import com.tangem.domain.models.wallet.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 timber.log.Timber
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
@ -43,7 +43,6 @@ internal class DefaultMarketsTokenRepository(
private val analyticsEventHandler: AnalyticsEventHandler,
private val cacheRegistry: CacheRegistry,
private val tokenExchangesStore: RuntimeStateStore<List<TokenMarketExchangesResponse.Exchange>>,
private val maxApyStore: RuntimeStateStore<BigDecimal?>,
private val networkFactory: NetworkFactory,
excludedBlockchains: ExcludedBlockchains,
) : MarketsTokenRepository {
@ -95,8 +94,6 @@ internal class DefaultMarketsTokenRepository(
val tokenMarketListWithMaxApy = TokenMarketListConverter.convert(res)
maxApyStore.store(tokenMarketListWithMaxApy.maxApy)
return BatchFetchResult.Success(
data = tokenMarketListWithMaxApy.tokens,
last = last,
@ -294,8 +291,24 @@ internal class DefaultMarketsTokenRepository(
}
}
override suspend fun getMaxApy(): Flow<BigDecimal?> {
return maxApyStore.get()
override suspend fun showYieldModePromo(
appCurrency: AppCurrency,
interval: TokenMarketListConfig.Interval,
): Boolean = try {
val hasYieldSupplyTokens = marketsApi.getCoinsList(
currency = appCurrency.code,
interval = interval.toRequestParam(),
order = TokenMarketListConfig.Order.YieldSupply.toRequestParam(),
offset = 0,
limit = 40,
timestamp = null,
search = null,
).getOrThrow().tokens.isNotEmpty()
hasYieldSupplyTokens
} catch (error: Exception) {
Timber.e(error)
false
}
inline fun <T> catchListErrorAndSendEvent(block: () -> T): T {

View file

@ -16,6 +16,7 @@ internal fun TokenMarketListConfig.Order.toRequestParam(): String = when (this)
TokenMarketListConfig.Order.TopGainers -> "gainers"
TokenMarketListConfig.Order.TopLosers -> "losers"
TokenMarketListConfig.Order.Staking -> "staking"
TokenMarketListConfig.Order.YieldSupply -> "yield"
}
internal fun PriceChangeInterval.toRequestParam(): String = when (this) {

View file

@ -40,7 +40,7 @@ internal object TokenMarketListConverter : Converter<TokenMarketListResponse, To
monthChangePercent = token.priceChangePercentage?.day30?.movePointLeft(2),
),
tokenCharts = TokenMarket.Charts(h24 = null, week = null, month = null),
stakingRate = stakingRate,
yieldRate = stakingRate ?: token.maxYieldApy?.movePointLeft(2),
updateTimestamp = value.timestamp,
)
}

View file

@ -42,7 +42,6 @@ internal object MarketsDataModule {
cacheRegistry = cacheRegistry,
tokenExchangesStore = RuntimeStateStore(defaultValue = emptyList()),
excludedBlockchains = excludedBlockchains,
maxApyStore = RuntimeStateStore(defaultValue = null),
networkFactory = networkFactory,
)
}

View file

@ -90,16 +90,16 @@ internal class DefaultPromoRepository(
appPreferencesStore.store(PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), false)
}
override fun isMarketsStakingNotificationHideClicked(): Flow<Boolean> {
override fun isMarketsYieldSupplyNotificationHideClicked(): Flow<Boolean> {
return appPreferencesStore.get(
key = PreferencesKeys.MARKETS_STAKING_NOTIFICATION_HIDE_CLICKED_KEY,
key = PreferencesKeys.MARKETS_YIELD_SUPPLY_NOTIFICATION_HIDE_CLICKED_KEY,
default = false,
)
}
override suspend fun setMarketsStakingNotificationHideClicked() {
override suspend fun setMarketsYieldSupplyNotificationHideClicked() {
appPreferencesStore.store(
key = PreferencesKeys.MARKETS_STAKING_NOTIFICATION_HIDE_CLICKED_KEY,
key = PreferencesKeys.MARKETS_YIELD_SUPPLY_NOTIFICATION_HIDE_CLICKED_KEY,
value = true,
)
}

View file

@ -12,7 +12,7 @@ data class TokenMarket(
val isUnderMarketCapLimit: Boolean,
val tokenQuotesShort: TokenQuotesShort,
val tokenCharts: Charts,
val stakingRate: BigDecimal?,
val yieldRate: BigDecimal?,
val updateTimestamp: Long?,
private val imageHost: String,
) {

View file

@ -8,7 +8,7 @@ data class TokenMarketListConfig(
) {
enum class Order {
ByRating, Trending, Buyers, TopGainers, TopLosers, Staking
ByRating, Trending, Buyers, TopGainers, TopLosers, Staking, YieldSupply,
}
enum class Interval {

View file

@ -1,39 +0,0 @@
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<BigDecimal?> {
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
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.domain.markets
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.repositories.MarketsTokenRepository
import com.tangem.domain.promo.PromoRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ShouldShowYieldModeMarketPromoUseCase(
private val promoRepository: PromoRepository,
private val marketsTokenRepository: MarketsTokenRepository,
) {
operator fun invoke(appCurrency: AppCurrency, interval: TokenMarketListConfig.Interval): Flow<Boolean> {
val hideClickedFlow = promoRepository.isMarketsYieldSupplyNotificationHideClicked()
return hideClickedFlow.map { hideClicked ->
!hideClicked && marketsTokenRepository.showYieldModePromo(
appCurrency = appCurrency,
interval = interval,
)
}
}
}

View file

@ -1,11 +1,10 @@
package com.tangem.domain.markets.repositories
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import java.math.BigDecimal
interface MarketsTokenRepository {
@ -56,5 +55,5 @@ interface MarketsTokenRepository {
*/
suspend fun getTokenExchanges(tokenId: CryptoCurrency.RawID): List<TokenMarketExchange>
suspend fun getMaxApy(): Flow<BigDecimal?>
suspend fun showYieldModePromo(appCurrency: AppCurrency, interval: TokenMarketListConfig.Interval): Boolean
}

View file

@ -16,9 +16,9 @@ interface PromoRepository {
suspend fun setNeverToShowTokenPromo(promoId: PromoId)
fun isMarketsStakingNotificationHideClicked(): Flow<Boolean>
fun isMarketsYieldSupplyNotificationHideClicked(): Flow<Boolean>
suspend fun setMarketsStakingNotificationHideClicked()
suspend fun setMarketsYieldSupplyNotificationHideClicked()
suspend fun isMoonpayPromoActive(): Boolean
// endregion

View file

@ -91,7 +91,7 @@ internal class UserWalletListModel @Inject constructor(
private fun onAddNewWalletClick() {
if (hotWalletFeatureToggles.isHotWalletEnabled) {
analyticsEventHandler.send(SignIn.ButtonAddWallet(AnalyticsParam.ScreensSources.SignIn))
analyticsEventHandler.send(SignIn.ButtonAddWallet(AnalyticsParam.ScreensSources.Settings))
router.push(AppRoute.CreateWalletSelection)
} else {
withProgress(isWalletSavingInProgress) {
@ -109,6 +109,7 @@ internal class UserWalletListModel @Inject constructor(
Timber.e("Failed to unlock wallet $userWalletId: $error")
error.handle(
onUserCancelled = {},
isFromUnlockAll = false,
onAlreadyUnlocked = { router.push(AppRoute.WalletSettings(userWalletId)) },
analyticsEventHandler = analyticsEventHandler,
showMessage = messageSender::send,

View file

@ -38,7 +38,7 @@ internal class MarketsTokenItemConverter(
trendType = value.getTrendType(),
chartData = value.getChartData(),
isUnder100kMarketCap = value.isUnderMarketCapLimit,
stakingRate = value.stakingRate?.format { percent() }?.let {
stakingRate = value.yieldRate?.format { percent() }?.let {
resourceReference(R.string.markets_apy_placeholder, wrappedList(it))
},
updateTimestamp = value.updateTimestamp,

View file

@ -338,6 +338,7 @@ internal class FeedComponentModel @Inject constructor(
SortByTypeUM.TopGainers -> TokenMarketListConfig.Order.TopGainers
SortByTypeUM.TopLosers -> TokenMarketListConfig.Order.TopLosers
SortByTypeUM.Staking -> TokenMarketListConfig.Order.Staking
SortByTypeUM.YieldSupply -> TokenMarketListConfig.Order.YieldSupply
}
}

View file

@ -386,6 +386,7 @@ internal class FeedMarketsBatchFlowManager(
TokenMarketListConfig.Order.TopGainers -> SortByTypeUM.TopGainers
TokenMarketListConfig.Order.TopLosers -> SortByTypeUM.TopLosers
TokenMarketListConfig.Order.Staking -> SortByTypeUM.Staking
TokenMarketListConfig.Order.YieldSupply -> SortByTypeUM.YieldSupply
}
}
}

View file

@ -41,6 +41,7 @@ enum class SortByTypeUM(val text: TextReference) {
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)),
YieldSupply(resourceReference(R.string.markets_sort_by_yield_mode_title)),
}
@Immutable

View file

@ -31,7 +31,6 @@ internal class HotWalletStepperModel @Inject constructor(
}
fun onSkipClick() {
// TODO send analytics
params.callback.onSkipClick()
}
}

View file

@ -24,6 +24,7 @@ internal sealed class MarketsListAnalyticsEvent(
SortByTypeUM.TopGainers -> "Gainers"
SortByTypeUM.TopLosers -> "Losers"
SortByTypeUM.Staking -> "Staking"
SortByTypeUM.YieldSupply -> "Yield Supply"
},
"Period" to when (interval) {
MarketsListUM.TrendInterval.H24 -> "24h"
@ -32,12 +33,11 @@ internal sealed class MarketsListAnalyticsEvent(
},
),
)
class YieldModePromoShown : MarketsListAnalyticsEvent(event = "Notice - Yield Mode Promo")
class StakingPromoShown : MarketsListAnalyticsEvent(event = "Notice - Staking Promo")
class YieldModePromoClosed : MarketsListAnalyticsEvent(event = "Yield Mode Promo Closed")
class StakingPromoClosed : MarketsListAnalyticsEvent(event = "Staking Promo Closed")
class StakingMoreInfoClicked : MarketsListAnalyticsEvent(event = "Staking More Info")
class YieldModeMoreInfoClicked : MarketsListAnalyticsEvent(event = "Yield Mode More Info")
data class TokenSearched(val tokenFound: Boolean) : MarketsListAnalyticsEvent(
event = "Token Searched",

View file

@ -10,19 +10,20 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
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.ShouldShowYieldModeMarketPromoUseCase
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.models.currency.CryptoCurrency
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.features.markets.tokenlist.impl.analytics.MarketsListAnalyticsEvent
import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListBatchFlowManager
import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListUMStateManager
import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM
import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval
import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -31,7 +32,6 @@ 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
@ -45,7 +45,7 @@ internal class MarketsListModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getStakingNotificationMaxApyUseCase: GetStakingNotificationMaxApyUseCase,
shouldShowYieldModeMarketPromoUseCase: ShouldShowYieldModeMarketPromoUseCase,
private val promoRepository: PromoRepository,
private val getUserCountryUseCase: GetUserCountryUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
@ -69,8 +69,6 @@ internal class MarketsListModel @Inject constructor(
visibleItemsChanged = { visibleItemIds.value = it },
onRetryButtonClicked = { activeListManager.reload() },
onTokenClick = { onTokenUIClicked(it) },
onStakingNotificationClick = { analyticsEventHandler.send(MarketsListAnalyticsEvent.StakingMoreInfoClicked()) },
onStakingNotificationCloseClick = { onStakingNotificationCloseClick() },
onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) },
)
@ -112,53 +110,62 @@ internal class MarketsListModel @Inject constructor(
marketsListUMStateManager.isInSearchStateFlow.flatMapLatest { isInSearchMode ->
if (isInSearchMode) {
combine(
searchMarketsListManager.uiItems,
searchMarketsListManager.isInInitialLoadingErrorState,
searchMarketsListManager.isSearchNotFoundState,
getStakingNotificationMaxApyUseCase(),
getUserCountryUseCase.invoke(),
) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, stakingMaxApy, userCountry ->
flow = searchMarketsListManager.uiItems,
flow2 = searchMarketsListManager.isInInitialLoadingErrorState,
flow3 = searchMarketsListManager.isSearchNotFoundState,
flow4 = shouldShowYieldModeMarketPromoUseCase(
appCurrency = currentAppCurrency.value,
interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(),
),
flow5 = getUserCountryUseCase.invoke(),
) { uiItems, isInInitialLoadingErrorState, isSearchNotFoundState, isYieldModePromo, userCountry ->
MarketsItemsData(
items = uiItems,
isInErrorState = isInInitialLoadingErrorState,
isSearchNotFound = isSearchNotFoundState,
stakingNotificationMaxApy = stakingMaxApy,
shouldShowYieldModePromo = isYieldModePromo,
userCountry = userCountry,
)
}
} else {
combine(
mainMarketsListManager.uiItems,
mainMarketsListManager.isInInitialLoadingErrorState,
getStakingNotificationMaxApyUseCase(),
getUserCountryUseCase.invoke(),
) { uiItems, isInInitialLoadingErrorState, stakingNotificationMaxApy, userCountry ->
flow = mainMarketsListManager.uiItems,
flow2 = mainMarketsListManager.isInInitialLoadingErrorState,
flow3 = shouldShowYieldModeMarketPromoUseCase(
appCurrency = currentAppCurrency.value,
interval = marketsListUMStateManager.selectedInterval.toBatchRequestInterval(),
),
flow4 = getUserCountryUseCase.invoke(),
) { uiItems, isInInitialLoadingErrorState, shouldShowYieldModePromo, userCountry ->
MarketsItemsData(
items = uiItems,
isInErrorState = isInInitialLoadingErrorState,
isSearchNotFound = false,
stakingNotificationMaxApy = stakingNotificationMaxApy,
shouldShowYieldModePromo = shouldShowYieldModePromo,
userCountry = userCountry,
)
}
}
}.collect { marketsItemsData ->
val stakingNotificationMaxApy = marketsItemsData.stakingNotificationMaxApy?.takeUnless {
marketsItemsData.userCountry.getOrNull().needApplyFCARestrictions()
}
if (marketsListUMStateManager.state.value.stakingNotificationMaxApy == null &&
stakingNotificationMaxApy != null
) {
analyticsEventHandler.send(MarketsListAnalyticsEvent.StakingPromoShown())
val shouldShowYieldModePromo = marketsItemsData.shouldShowYieldModePromo
if (marketsListUMStateManager.state.value.marketsNotificationUM == null && shouldShowYieldModePromo) {
analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModePromoShown())
}
marketsListUMStateManager.onUiItemsChanged(
uiItems = marketsItemsData.items,
isInErrorState = marketsItemsData.isInErrorState,
isSearchNotFound = marketsItemsData.isSearchNotFound,
stakingNotificationMaxApy = marketsItemsData.stakingNotificationMaxApy?.takeUnless {
marketsItemsData.userCountry.getOrNull().needApplyFCARestrictions()
marketsNotificationUM = if (shouldShowYieldModePromo) {
MarketsNotificationUM.YieldSupplyPromo(
onClick = {
analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModeMoreInfoClicked())
marketsListUMStateManager.selectedSortByType = SortByTypeUM.YieldSupply
},
onCloseClick = { onYieldModeNotificationCloseClick() },
)
} else {
null
},
)
}
@ -266,6 +273,14 @@ internal class MarketsListModel @Inject constructor(
mainMarketsListManager.reload()
}
fun TrendInterval.toBatchRequestInterval(): TokenMarketListConfig.Interval {
return when (this) {
TrendInterval.H24 -> TokenMarketListConfig.Interval.H24
TrendInterval.D7 -> TokenMarketListConfig.Interval.WEEK
TrendInterval.M1 -> TokenMarketListConfig.Interval.MONTH
}
}
private fun initAnalytics() {
containerBottomSheetState.onEach { bottomSheetState ->
if (bottomSheetState == BottomSheetState.EXPANDED) {
@ -302,10 +317,10 @@ internal class MarketsListModel @Inject constructor(
}.saveIn(updateQuotesJob)
}
private fun onStakingNotificationCloseClick() {
analyticsEventHandler.send(MarketsListAnalyticsEvent.StakingPromoClosed())
private fun onYieldModeNotificationCloseClick() {
analyticsEventHandler.send(MarketsListAnalyticsEvent.YieldModePromoClosed())
modelScope.launch {
promoRepository.setMarketsStakingNotificationHideClicked()
promoRepository.setMarketsYieldSupplyNotificationHideClicked()
}
}
@ -313,7 +328,7 @@ internal class MarketsListModel @Inject constructor(
val items: ImmutableList<MarketsListItemUM>,
val isInErrorState: Boolean,
val isSearchNotFound: Boolean,
val stakingNotificationMaxApy: BigDecimal?,
val shouldShowYieldModePromo: Boolean,
val userCountry: Either<UserCountryError, UserCountry>,
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.markets.tokenlist.impl.model
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.markets.impl.R
internal sealed class MarketsNotificationUM(val config: NotificationConfig) {
data class YieldSupplyPromo(
val onClick: () -> Unit,
val onCloseClick: () -> Unit,
) : MarketsNotificationUM(
config = NotificationConfig(
iconResId = R.drawable.img_yield_supply_in_market_notification,
title = resourceReference(R.string.markets_yield_supply_banner_title),
subtitle = TextReference.EMPTY,
onClick = onClick,
onCloseClick = onCloseClick,
),
)
}

View file

@ -38,7 +38,7 @@ internal class MarketsTokenItemConverter(
trendType = value.getTrendType(),
chartData = value.getChartData(),
isUnder100kMarketCap = value.isUnderMarketCapLimit,
stakingRate = value.stakingRate?.format { percent() }?.let {
stakingRate = value.yieldRate?.format { percent() }?.let {
resourceReference(R.string.markets_apy_placeholder, wrappedList(it))
},
updateTimestamp = value.updateTimestamp,

View file

@ -343,6 +343,7 @@ internal class MarketsListBatchFlowManager(
SortByTypeUM.TopGainers -> TokenMarketListConfig.Order.TopGainers
SortByTypeUM.TopLosers -> TokenMarketListConfig.Order.TopLosers
SortByTypeUM.Staking -> TokenMarketListConfig.Order.Staking
SortByTypeUM.YieldSupply -> TokenMarketListConfig.Order.YieldSupply
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.event.triggeredEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.model.MarketsNotificationUM
import com.tangem.features.markets.tokenlist.impl.ui.state.*
import com.tangem.utils.Provider
import kotlinx.collections.immutable.ImmutableList
@ -16,7 +17,6 @@ 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")
@ -26,8 +26,6 @@ internal class MarketsListUMStateManager(
private val visibleItemsChanged: (itemsKeys: List<CryptoCurrency.RawID>) -> Unit,
private val onRetryButtonClicked: () -> Unit,
private val onTokenClick: (MarketsListItemUM) -> Unit,
private val onStakingNotificationClick: () -> Unit,
private val onStakingNotificationCloseClick: () -> Unit,
private val onShowTokensUnder100kClicked: () -> Unit,
) {
@ -92,25 +90,25 @@ internal class MarketsListUMStateManager(
isInErrorState: Boolean,
isSearchNotFound: Boolean,
uiItems: ImmutableList<MarketsListItemUM>,
stakingNotificationMaxApy: BigDecimal?,
marketsNotificationUM: MarketsNotificationUM?,
) {
state.update {
state.update { currentState ->
when {
isInErrorState -> {
it.copy(
currentState.copy(
list = ListUM.LoadingError(onRetryClicked = onRetryButtonClicked),
)
}
isSearchNotFound -> {
it.copy(list = ListUM.SearchNothingFound)
currentState.copy(list = ListUM.SearchNothingFound)
}
uiItems.isEmpty() -> {
it.copy(list = ListUM.Loading)
currentState.copy(list = ListUM.Loading)
}
else -> {
it.updateItems(
currentState.updateItems(
newItems = uiItems,
stakingNotificationMaxApy = stakingNotificationMaxApy,
marketsNotificationUM = marketsNotificationUM,
)
}
}
@ -119,7 +117,7 @@ internal class MarketsListUMStateManager(
private fun MarketsListUM.updateItems(
newItems: ImmutableList<MarketsListItemUM>,
stakingNotificationMaxApy: BigDecimal?,
marketsNotificationUM: MarketsNotificationUM?,
): MarketsListUM {
val currentState = this
@ -131,7 +129,7 @@ internal class MarketsListUMStateManager(
.copy(
showUnder100kTokensNotificationWasHidden = currentState.showUnder100kButtonAlreadyPressed(),
),
stakingNotificationMaxApy = stakingNotificationMaxApy,
marketsNotificationUM = marketsNotificationUM,
)
}
@ -225,12 +223,7 @@ internal class MarketsListUMStateManager(
onOptionClicked = ::onBottomSheetOptionClicked,
),
),
stakingNotificationMaxApy = null,
onStakingNotificationClick = {
onStakingNotificationClick()
selectedSortByType = SortByTypeUM.Staking
},
onStakingNotificationCloseClick = onStakingNotificationCloseClick,
marketsNotificationUM = null,
)
private fun onBottomSheetOptionClicked(sortByTypeUM: SortByTypeUM) {

View file

@ -22,7 +22,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -38,19 +37,17 @@ 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.*
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
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.model.MarketsNotificationUM
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.components.YieldSupplyInMarketsPromoNotification
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
@ -58,7 +55,6 @@ 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"
@ -180,38 +176,38 @@ private fun ColumnScope.Content(state: MarketsListUM, modifier: Modifier = Modif
)
}
val marketsNotification = state.marketsNotificationUM
AnimatedVisibility(
state.isInSearchMode.not() &&
state.stakingNotificationMaxApy != null &&
state.selectedSortBy != SortByTypeUM.Staking,
state.selectedSortBy != SortByTypeUM.YieldSupply,
) {
val showMore = stringResourceSafe(R.string.common_show_more)
val description = stringResourceSafe(
R.string.markets_staking_banner_description_placeholder,
showMore,
)
val clickableDescription = buildAnnotatedString {
append(description.substringBefore(showMore))
when (marketsNotification) {
is MarketsNotificationUM.YieldSupplyPromo -> {
val description = stringResourceSafe(
R.string.markets_yield_supply_banner_description,
showMore,
)
pushStringAnnotation(SHOW_MORE_KEY, "")
appendColored(showMore, TangemTheme.colors.text.accent)
pop()
val clickableDescription = annotatedReference {
append(description.substringBefore(showMore))
pushStringAnnotation(SHOW_MORE_KEY, "")
appendColored(showMore, TangemTheme.colors.text.accent)
pop()
}
YieldSupplyInMarketsPromoNotification(
config = marketsNotification.config.copy(
subtitle = clickableDescription,
),
modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12),
)
}
else -> { /* no-op */
}
}
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),
)
}
}
}
@ -420,9 +416,10 @@ private fun Preview() {
onDismissRequest = {},
content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {},
),
stakingNotificationMaxApy = BigDecimal(0.12345),
onStakingNotificationClick = {},
onStakingNotificationCloseClick = {},
marketsNotificationUM = MarketsNotificationUM.YieldSupplyPromo(
onClick = {},
onCloseClick = {},
),
),
onHeaderSizeChange = {},
bottomSheetState = BottomSheetState.EXPANDED,

View file

@ -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(0x2684B0D7)
private val borderColor = Color(0x2684B0D7)
@Composable
fun YieldSupplyInMarketsPromoNotification(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(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(id = iconRes),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.requiredWidth(80.dp)
.wrapContentHeight(Alignment.CenterVertically, 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 = 80.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 {
YieldSupplyInMarketsPromoNotification(
config = NotificationConfig(
title = stringReference("Activate Yield Mode"),
subtitle = stringReference("Power up your assets while supplying them with instant access. Show more"),
iconResId = R.drawable.img_yield_supply_in_market_notification,
onCloseClick = { },
),
)
}
}
// endregion

View file

@ -8,8 +8,8 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.markets.impl.R
import com.tangem.features.markets.tokenlist.impl.model.MarketsNotificationUM
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
internal data class MarketsListUM(
val list: ListUM,
@ -19,9 +19,7 @@ internal data class MarketsListUM(
val selectedInterval: TrendInterval,
val onIntervalClick: (TrendInterval) -> Unit,
val onSortByButtonClick: () -> Unit,
val stakingNotificationMaxApy: BigDecimal?,
val onStakingNotificationClick: () -> Unit,
val onStakingNotificationCloseClick: () -> Unit,
val marketsNotificationUM: MarketsNotificationUM?,
) {
val isInSearchMode
get() = searchBar.isActive
@ -40,6 +38,7 @@ enum class SortByTypeUM(val text: TextReference) {
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)),
YieldSupply(resourceReference(R.string.yield_module_earn_sheet_title)),
}
@Immutable

View file

@ -200,6 +200,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor(
onAlreadyUnlocked = {},
onUserCancelled = {},
analyticsEventHandler = analyticsEventHandler,
isFromUnlockAll = true,
showMessage = uiMessageSender::send,
)
}

View file

@ -108,9 +108,10 @@ internal class WelcomeModel @Inject constructor(
routedOut = true
router.replaceAll(AppRoute.Wallet)
}
.onLeft {
it.handle(
.onLeft { error ->
error.handle(
specificWalletId = null,
isFromUnlockAll = true,
onUserCancelled = { tryToUnlockWithAccessCodeRightAway() },
)
setSelectWalletState()
@ -146,8 +147,12 @@ internal class WelcomeModel @Inject constructor(
.onRight {
router.replaceAll(AppRoute.Wallet)
}
.onLeft {
it.handle(null, onUserCancelled = { /* ignore */ })
.onLeft { error ->
error.handle(
specificWalletId = null,
isFromUnlockAll = true,
onUserCancelled = { /* ignore */ },
)
}
}
},
@ -202,11 +207,19 @@ internal class WelcomeModel @Inject constructor(
router.replaceAll(AppRoute.Wallet)
}
.onLeft { error ->
error.handle(specificWalletId = userWalletId, onUserCancelled = { /* ignore*/ })
error.handle(
specificWalletId = userWalletId,
isFromUnlockAll = false,
onUserCancelled = { /* ignore*/ },
)
}
}
suspend fun UnlockWalletError.handle(specificWalletId: UserWalletId?, onUserCancelled: suspend () -> Unit = { }) {
suspend fun UnlockWalletError.handle(
specificWalletId: UserWalletId?,
isFromUnlockAll: Boolean,
onUserCancelled: suspend () -> Unit = { },
) {
handle(
onAlreadyUnlocked = {
// this should not happen, as we check for locked state before this
@ -215,6 +228,7 @@ internal class WelcomeModel @Inject constructor(
},
onUserCancelled = { onUserCancelled() },
analyticsEventHandler = analyticsEventHandler,
isFromUnlockAll = isFromUnlockAll,
showMessage = uiMessageSender::send,
)
}