From 6aab25427d447f211bbcd93571d711854ec44ee9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 26 May 2026 16:03:11 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/YieldSupplyDomainModule.kt | 33 +++ .../tangem/tap/routing/utils/ChildFactory.kt | 1 + .../com/tangem/common/routing/AppRoute.kt | 1 + .../com/tangem/common/TangemSiteUrlBuilder.kt | 2 + .../configs/feature_toggles_config.json | 4 + .../promotion/models/PromotionsResponse.kt | 39 +++ .../models/YieldBoostStatusResponse.kt | 17 ++ .../api/tangemTech/TangemTechApi.kt | 14 + .../tangem/datasource/di/YieldSupplyModule.kt | 17 ++ .../promo/DefaultYieldBoostPromoStore.kt | 20 ++ .../promo/DefaultYieldBoostStatusStore.kt | 20 ++ .../yieldsupply/promo/YieldBoostPromoStore.kt | 11 + .../promo/YieldBoostStatusStore.kt | 11 + core/res/src/main/res/values-de/strings.xml | 12 + core/res/src/main/res/values-es/strings.xml | 26 ++ core/res/src/main/res/values-fr/strings.xml | 8 + core/res/src/main/res/values-it/strings.xml | 4 + core/res/src/main/res/values-ja/strings.xml | 6 + .../src/main/res/values-pt-rBR/strings.xml | 11 + core/res/src/main/res/values-ru/strings.xml | 26 +- .../src/main/res/values-uk-rUA/strings.xml | 24 ++ .../src/main/res/values-zh-rCN/strings.xml | 6 + .../src/main/res/values-zh-rTW/strings.xml | 4 + .../components/notifications/Notification.kt | 5 +- .../main/res/drawable/ic_gift_promo_24.xml | 18 ++ data/yield-supply/build.gradle.kts | 1 + .../yield/supply/di/YieldSupplyDataModule.kt | 21 ++ .../promo/DefaultYieldPromoRepository.kt | 63 +++++ .../converter/YieldBoostPromoConverter.kt | 34 +++ .../converter/YieldBoostStatusConverter.kt | 63 +++++ .../converter/YieldBoostPromoConverterTest.kt | 119 +++++++++ .../YieldBoostStatusConverterTest.kt | 186 +++++++++++++ .../domain/stories/models/StoryContent.kt | 1 + domain/yield-supply/build.gradle.kts | 1 + domain/yield-supply/models/build.gradle.kts | 1 + .../yield/supply/models/YieldBoostPromo.kt | 24 ++ .../yield/supply/models/YieldBoostStatus.kt | 35 +++ .../supply/promo/YieldPromoRepository.kt | 20 ++ .../promo/usecase/GetBoostedApyUseCase.kt | 16 ++ .../usecase/GetYieldBoostStatusUseCase.kt | 18 ++ ...IsYieldBoostPromoEnabledForTokenUseCase.kt | 47 ++++ .../ShouldShowYieldBoostMainBannerUseCase.kt | 31 +++ .../promo/usecase/GetBoostedApyUseCaseTest.kt | 38 +++ ...eldBoostPromoEnabledForTokenUseCaseTest.kt | 246 ++++++++++++++++++ ...ouldShowYieldBoostMainBannerUseCaseTest.kt | 104 ++++++++ .../feature/stories/api/StoriesComponent.kt | 1 + .../stories/impl/StoriesSlideConfigs.kt | 21 ++ .../stories/impl/model/StoriesModel.kt | 2 +- .../intents/WalletWarningsClickIntents.kt | 24 ++ .../utils/WalletWarningsAnalyticsSender.kt | 1 + .../domain/GetMultiWalletWarningsFactory.kt | 34 +++ .../wallet/state/model/WalletNotification.kt | 22 ++ .../MultiWalletWarningsSubscriber.kt | 14 + .../components/common/WalletNotifications.kt | 29 +++ .../supply/api/YieldSupplyFeatureToggles.kt | 5 + .../supply/api/YieldSupplyPromoComponent.kt | 1 + .../supply/api/entry/YieldSupplyEntryRoute.kt | 1 + features/yield-supply/impl/build.gradle.kts | 3 + .../impl/DefaultYieldSupplyFeatureToggles.kt | 15 ++ .../supply/impl/YieldBoostStoryPreloader.kt | 28 ++ .../entity/YieldSupplyActiveContentUM.kt | 2 + .../active/model/YieldSupplyActiveModel.kt | 86 ++++++ .../active/ui/YieldSupplyActiveContent.kt | 57 +++- .../impl/di/YieldSupplyFeatureModule.kt | 21 ++ .../entry/DefaultYieldSupplyEntryComponent.kt | 1 + .../impl/entry/model/YieldSupplyEntryModel.kt | 17 +- .../supply/impl/main/entity/YieldSupplyUM.kt | 2 + .../main/model/YieldSupplyClickIntents.kt | 1 + .../impl/main/model/YieldSupplyModel.kt | 47 +++- ...ieldSupplyTokenStatusSuccessTransformer.kt | 41 ++- .../main/ui/YieldSupplyBlockContentLegacy.kt | 100 ++++++- .../impl/promo/entity/YieldSupplyPromoUM.kt | 4 + .../impl/promo/model/YieldSupplyPromoModel.kt | 50 +++- .../impl/promo/ui/YieldSupplyPromoContent.kt | 139 +++++++++- .../YieldSupplyToEarnBlockConverterTest.kt | 1 + 75 files changed, 2122 insertions(+), 57 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/YieldBoostStatusResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/DefaultYieldBoostPromoStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/DefaultYieldBoostStatusStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/YieldBoostPromoStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/YieldBoostStatusStore.kt create mode 100644 core/ui/src/main/res/drawable/ic_gift_promo_24.xml create mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt create mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverter.kt create mode 100644 data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverter.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverterTest.kt create mode 100644 domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostPromo.kt create mode 100644 domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostStatus.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/YieldPromoRepository.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/GetBoostedApyUseCase.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/GetYieldBoostStatusUseCase.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCase.kt create mode 100644 domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCase.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/GetBoostedApyUseCaseTest.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt create mode 100644 features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/YieldBoostStoryPreloader.kt create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt index 79220d1566..e16015f499 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/YieldSupplyDomainModule.kt @@ -10,6 +10,11 @@ import com.tangem.domain.transaction.error.FeeErrorResolver import com.tangem.domain.yield.supply.YieldSupplyErrorResolver import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.domain.yield.supply.promo.YieldPromoRepository +import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase +import com.tangem.domain.yield.supply.promo.usecase.GetYieldBoostStatusUseCase +import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase +import com.tangem.domain.yield.supply.promo.usecase.ShouldShowYieldBoostMainBannerUseCase import com.tangem.domain.yield.supply.usecase.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -260,4 +265,32 @@ internal object YieldSupplyDomainModule { coroutineScope = appScope, ) } + + // region yield-boost promo ([REDACTED_TASK_KEY]) + @Provides + @Singleton + fun provideGetBoostedApyUseCase(): GetBoostedApyUseCase = GetBoostedApyUseCase() + + @Provides + @Singleton + fun provideGetYieldBoostStatusUseCase(repository: YieldPromoRepository): GetYieldBoostStatusUseCase { + return GetYieldBoostStatusUseCase(repository) + } + + @Provides + @Singleton + fun provideIsYieldBoostPromoEnabledForTokenUseCase( + repository: YieldPromoRepository, + ): IsYieldBoostPromoEnabledForTokenUseCase { + return IsYieldBoostPromoEnabledForTokenUseCase(repository) + } + + @Provides + @Singleton + fun provideShouldShowYieldBoostMainBannerUseCase( + repository: YieldPromoRepository, + ): ShouldShowYieldBoostMainBannerUseCase { + return ShouldShowYieldBoostMainBannerUseCase(repository) + } + // endregion } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index d4980da8c2..da1aaddef4 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -290,6 +290,7 @@ internal class ChildFactory @Inject constructor( storyId = route.storyId, nextScreen = route.nextScreen, screenSource = route.screenSource, + shouldMarkAsSeenOnClose = route.shouldMarkAsSeenOnClose, ), componentFactory = storiesComponentFactory, ) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index eff538b261..fd1fd59c15 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -350,6 +350,7 @@ sealed class AppRoute(val path: String) : Route { val storyId: String, val nextScreen: AppRoute? = null, val screenSource: String, + val shouldMarkAsSeenOnClose: Boolean = true, ) : AppRoute(path = "/stories$storyId") @Serializable diff --git a/common/src/main/kotlin/com/tangem/common/TangemSiteUrlBuilder.kt b/common/src/main/kotlin/com/tangem/common/TangemSiteUrlBuilder.kt index 2b3a6e5db2..6523d011e2 100644 --- a/common/src/main/kotlin/com/tangem/common/TangemSiteUrlBuilder.kt +++ b/common/src/main/kotlin/com/tangem/common/TangemSiteUrlBuilder.kt @@ -14,6 +14,8 @@ object TangemSiteUrlBuilder { const val HELP_CENTER_SWAP_URL = "https://tangem.com/en/help-center/tangem-wallet-core-functionality/how-to-swap-coins-and-tokens/" + const val YIELD_MODE_TERMS_URL = "https://tangem.com/docs/en/yield-mode-terms.pdf" + suspend fun getUtmTags(campaign: String?): String { val langCode = Locale.getDefault().language val utmCampaignPart = campaign?.let { "&utm_campaign=$it-$langCode" }.orEmpty() diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index c41259567e..df9515601a 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -94,5 +94,9 @@ { "name": "AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED", "version": "undefined" + }, + { + "name": "AND_15154_YIELD_PROMO_ENABLED", + "version": "undefined" } ] diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt new file mode 100644 index 0000000000..6d9dea62b9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/PromotionsResponse.kt @@ -0,0 +1,39 @@ +package com.tangem.datasource.api.promotion.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PromotionsResponse( + @Json(name = "promotions") val promotions: List, +) { + + @JsonClass(generateAdapter = true) + data class PromotionDto( + @Json(name = "name") val name: String, + @Json(name = "all") val all: All?, + ) { + + @JsonClass(generateAdapter = true) + data class All( + @Json(name = "timeline") val timeline: Timeline, + @Json(name = "tokens") val tokens: List?, + @Json(name = "status") val status: String, + @Json(name = "link") val link: String?, + ) + + @JsonClass(generateAdapter = true) + data class Timeline( + @Json(name = "start") val start: String, + @Json(name = "end") val end: String, + ) + + @JsonClass(generateAdapter = true) + data class PromoToken( + @Json(name = "tokenAddress") val tokenAddress: String, + @Json(name = "tokenSymbol") val tokenSymbol: String, + @Json(name = "tokenName") val tokenName: String, + @Json(name = "networkId") val networkId: String, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/YieldBoostStatusResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/YieldBoostStatusResponse.kt new file mode 100644 index 0000000000..dd697566df --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/promotion/models/YieldBoostStatusResponse.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.api.promotion.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class YieldBoostStatusResponse( + @Json(name = "tokenName") val tokenName: String?, + @Json(name = "networkId") val networkId: String?, + @Json(name = "moduleAddress") val moduleAddress: String?, + @Json(name = "userAddress") val userAddress: String?, + @Json(name = "contractAddress") val contractAddress: String?, + @Json(name = "promoEnrollmentStatus") val promoEnrollmentStatus: String, + @Json(name = "activationDate") val activationDate: String?, + @Json(name = "qualificationEndDate") val qualificationEndDate: String?, + @Json(name = "disqualificationReason") val disqualificationReason: String?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index a1ddf4934c..0549a6634b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -1,6 +1,8 @@ package com.tangem.datasource.api.tangemTech import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse import com.tangem.datasource.api.stories.models.StoryContentResponse import com.tangem.datasource.api.tangemTech.models.* import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse @@ -118,6 +120,18 @@ interface TangemTechApi { @GET("v1/stories/{story_id}") suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse + // region yield-boost promo + @GET("/v2/promotion") + suspend fun getPromotions( + @Query("walletId") walletId: String, + @Header("Cache-Control") cacheControl: String = "max-age=600", + ): ApiResponse + + @Suppress("FunctionSignature", "TrailingCommaOnDeclarationSite") + @GET("/v2/promotion/yield-apr-boost/status") + suspend fun getYieldBoostStatus(@Query("walletId") walletId: String): ApiResponse + // endregion + // region push notifications @GET("v1/notification/push_notifications_eligible_networks") suspend fun getEligibleNetworksForPushNotifications(): ApiResponse> diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt index cb14310516..04d1795879 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/YieldSupplyModule.kt @@ -5,8 +5,13 @@ import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto +import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.yieldsupply.DefaultYieldMarketsStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore +import com.tangem.datasource.local.yieldsupply.promo.DefaultYieldBoostPromoStore +import com.tangem.datasource.local.yieldsupply.promo.DefaultYieldBoostStatusStore +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes import com.tangem.utils.coroutines.AppCoroutineScope @@ -40,4 +45,16 @@ object YieldSupplyModule { ), ) } + + @Provides + @Singleton + fun provideYieldBoostPromoStore(): YieldBoostPromoStore { + return DefaultYieldBoostPromoStore(dataStore = RuntimeSharedStore()) + } + + @Provides + @Singleton + fun provideYieldBoostStatusStore(): YieldBoostStatusStore { + return DefaultYieldBoostStatusStore(dataStore = RuntimeSharedStore()) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/DefaultYieldBoostPromoStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/DefaultYieldBoostPromoStore.kt new file mode 100644 index 0000000000..85e86af872 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/DefaultYieldBoostPromoStore.kt @@ -0,0 +1,20 @@ +package com.tangem.datasource.local.yieldsupply.promo + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo + +internal class DefaultYieldBoostPromoStore( + private val dataStore: RuntimeSharedStore>, +) : YieldBoostPromoStore { + + override suspend fun getSyncOrNull(userWalletId: UserWalletId): YieldBoostPromo? { + return dataStore.getSyncOrNull()?.get(userWalletId) + } + + override suspend fun store(userWalletId: UserWalletId, value: YieldBoostPromo) { + dataStore.update(emptyMap()) { current -> + current + (userWalletId to value) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/DefaultYieldBoostStatusStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/DefaultYieldBoostStatusStore.kt new file mode 100644 index 0000000000..9fbdf5d234 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/DefaultYieldBoostStatusStore.kt @@ -0,0 +1,20 @@ +package com.tangem.datasource.local.yieldsupply.promo + +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostStatus + +internal class DefaultYieldBoostStatusStore( + private val dataStore: RuntimeSharedStore>, +) : YieldBoostStatusStore { + + override suspend fun getSyncOrNull(userWalletId: UserWalletId): YieldBoostStatus? { + return dataStore.getSyncOrNull()?.get(userWalletId) + } + + override suspend fun store(userWalletId: UserWalletId, value: YieldBoostStatus) { + dataStore.update(emptyMap()) { current -> + current + (userWalletId to value) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/YieldBoostPromoStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/YieldBoostPromoStore.kt new file mode 100644 index 0000000000..d3c70376e9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/YieldBoostPromoStore.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.local.yieldsupply.promo + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo + +interface YieldBoostPromoStore { + + suspend fun getSyncOrNull(userWalletId: UserWalletId): YieldBoostPromo? + + suspend fun store(userWalletId: UserWalletId, value: YieldBoostPromo) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/YieldBoostStatusStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/YieldBoostStatusStore.kt new file mode 100644 index 0000000000..d37e97d003 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/yieldsupply/promo/YieldBoostStatusStore.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.local.yieldsupply.promo + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostStatus + +interface YieldBoostStatusStore { + + suspend fun getSyncOrNull(userWalletId: UserWalletId): YieldBoostStatus? + + suspend fun store(userWalletId: UserWalletId, value: YieldBoostStatus) +} \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 0d414b7743..98e3e4817c 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -403,6 +403,7 @@ Senden Gesendet Der Server ist nicht verfügbar. Bitte versuche es später erneut. + Sitzung abgelaufen Teilen Link teilen Weniger anzeigen @@ -1209,7 +1210,9 @@ Token organisieren Gruppe löschen %s Unterstützung + Genehmigung erteilen Push-Benachrichtigungen sind aktiviert, funktionieren aber erst, nachdem du Benachrichtigungen in den Geräteeinstellungen zugelassen hast. + Push-Benachrichtigungen sind aktiviert, funktionieren aber erst nach Ihrer Zustimmung. Benachrichtigungen zulassen Produktneuheiten, exklusive Angebote und Erinnerungen an Aktivitäten. Angebote & Updates @@ -1853,6 +1856,10 @@ Nutzen Sie USDC für alltägliche Zahlungen Tangem Pay ist vorübergehend nicht erreichbar. Tangem Pay + Senden Sie USDC Polygon an die Adresse Ihres Kontos + Von einer anderen Wallet oder Börse + Tauschen Sie beliebige Assets in USDC Polygon um + Aus Ihrer Tangem Wallet USDC im Polygon Klicken Sie auf die Schaltfläche unten, um den Zugriff wiederherzustellen Gelder aus erstatteten Käufen werden nicht auf Ihr On-Chain-Guthaben zurückerstattet und stehen nicht für Abhebungen zur Verfügung, bleiben aber auf Ihrem Kartenguthaben für Einkäufe verfügbar @@ -2369,6 +2376,10 @@ Sonderangebot für den Yield-Modus APY x3 yield_apy_boost_block_activate + Aktivieren dein Bonus + Transaktionsverlauf für Details prüfen + Bonus im Ertragsmodus ausgezahlt + %1$s tage übrig, um dein Bonus freizuschalten Du hast Anspruch auf einen 30-tägigen APY-Boost, es gelten die T&C, erfahren Sie mehr Aktiviere den Renditemodus zum ersten Mal und erhalte in den ersten 30 Tagen bis zu 3x Rendite Bonus für den ersten Monat APR @@ -2479,5 +2490,6 @@ Der Yield-Modus ist momentan nicht verfügbar. Bitte versuche es später erneut. Yield Mode nicht verfügbar Die Berechtigung zur Bonusauszahlung wird geprüft + Um Ihren Bonus freizuschalten, müssen Sie nur noch wenige Schritte verbleiben. Chart konnte nicht geladen werden... diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index a05cfcde15..298ebb9881 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -80,6 +80,7 @@ Añada tokens Seleccione el token que desea recibir Seleccione el token que desea intercambiar + Agregar tokens Elige red Agregue un token personalizado Gestionar tokens @@ -569,6 +570,7 @@ Proveedor Mejor tarifa Lista de advertencias de la FCA + Proveedor de intercambio Mejor opción Proveedor en la lista de advertencias de la FCA Disponible hasta %s @@ -731,6 +733,7 @@ Límite de Mana La red Koinos requiere Mana para las tarifas de red. Tienes %1$s/%2$s Mana Nivel de Mana + Añadir y Gestionar Para hacer un seguimiento de sus criptomonedas y transacciones, agregue tokens Gestionar tokens Escanee el código QR para enviar fondos o conectarse a una aplicación @@ -1089,16 +1092,29 @@ Esta transacción ya ha sido procesada. No se requiere ninguna otra acción. Obteniendo las mejores tarifas... Instantáneo + La verificación es gratuita y suele tardar entre 1 y 2 minutos. + Tangem no tendrá acceso a su información de identidad, usted comparte los datos directamente con el proveedor regulado + La verificación desbloquea el acceso completo a futuras transacciones con este proveedor + Elija otro método + Para cumplir los requisitos normativos locales, %@ exige la verificación de su identidad. + Verificación de identidad requerida por el proveedor de pago + Verificar + Lo importante Al utilizar la funcionalidad onramp, acepta %1$s y %2$s del proveedor. El servicio es proporcionado por un proveedor externo. \nTangem no es responsable. El monto de la compra no debe ser mayor a %s La cantidad a comprar debe ser como mínimo %s + El importe acumulado de la transacción superior a %1s puede requerir la verificación de la identidad con %2s + El importe acumulado de la transacción superior al equivalente de %1s puede requerir la verificación de la identidad con %2s + Al hacer clic en Pagar, usted acepta %1s\'s %2s y %3s. No hay proveedores disponibles para esta moneda Procesamiento más rápido Pagar con Método de pago Disponible hasta %s Disponible desde %s + Las tarjetas emitidas en EE.UU. y el Reino Unido no pueden procesarse por este método. El proveedor puede requerir una verificación de identidad adicional + Requisitos del proveedor %d proveedor %d proveedores @@ -1508,6 +1524,7 @@ Se requiere una transacción entrante de al menos %1$s para proceder Fondos insuficientes Al aprobar, permites que el contrato inteligente use tus tokens en futuras transacciones. + Modo detallado Tasa Fija La red cobrará una tasa de aprobación del token para verificar que usted autoriza el uso de su token para el intercambio. Intercambio en curso @@ -1516,6 +1533,7 @@ ¿Busca algo más?\n¡Intente buscar o explorar otra criptomoneda! Busque cualquier token, incluso si aún no está en su lista. Utilice la búsqueda para encontrar lo que necesite + Modo sencillo Siéntase seguro con una asistencia permanente que le ayudará con cualquier problema Siempre aquí Múltiples proveedores de confianza en un solo lugar: intercambie cualquier activo sin esfuerzo en su billetera @@ -1552,6 +1570,10 @@ Fondos insuficientes No hay fondos suficientes para completar esta transacción. Reduzca el importe a recibir o añada más fondos. Dar autorización + Valore su experiencia con el proveedor + Escriba sus comentarios + Enviar comentarios + ¿Qué influyó en su \nexperiencia? Intercambiar Intercambiando... Usted recibe @@ -1724,6 +1746,10 @@ Usa USDC para pagos cotidianos Tangem Pay no está disponible temporalmente. Tangem Pay + Envía USDC Polygon a la dirección de tu cuenta + Desde otra billetera o exchange + Intercambia cualquier activo por USDC Polygon + Desde tu Tangem Wallet USDC en Polygon Haga clic en el botón de abajo para restaurar el acceso Los fondos de compras reembolsadas no se devolverán a tu saldo on-chain Polygon ni estarán disponibles para retiro, pero permanecerán en tu saldo de tarjeta para compras diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index ff36e3ed4e..68600b3f36 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1497,6 +1497,10 @@ Impact élevé sur les prix Fonds insuffisants Donner l\'autorisation + Évaluez votre expérience avec ce prestataire + Saisissez votre avis + Envoyez votre avis + Qu\'est-ce qui a influencé votre \nexpérience ? Échanger Échange... Vous recevez à @@ -1666,6 +1670,10 @@ Utilisez USDC pour les paiements quotidiens Tangem Pay est temporairement indisponible Tangem Pay + Envoyez USDC Polygon à l\'adresse de votre compte + Depuis un autre wallet ou exchange + Échangez n\'importe quel actif contre USDC Polygon + Depuis votre Tangem Wallet USDC sur Polygon Cliquez sur le bouton ci-dessous pour restaurer l\'accès Les fonds des achats remboursés ne seront pas retournés à votre solde sur Polygon ni disponibles pour un retrait, mais resteront sur votre solde de carte pour vos achats diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 82a796f48e..066a70f131 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -218,6 +218,10 @@ Usa USDC per i pagamenti quotidiani Tangem Pay è temporaneamente non disponibile Tangem Pay + Invia USDC Polygon all\'indirizzo del tuo account + Da un altro wallet o exchange + Converti qualsiasi asset in USDC Polygon + Dal tuo Tangem Wallet USDC sulla Polygon Fare clic sul pulsante in basso per ripristinare l\'accesso I fondi degli acquisti rimborsati non verranno restituiti al tuo saldo on-chain Polygon né saranno disponibili per il prelievo, ma rimarranno sul saldo della tua carta per gli acquisti diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index f7f5c9d0cb..e7eb4ac259 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1187,7 +1187,9 @@ トークンを整理する グループ解除 %sサポート + 許可する プッシュ通知は有効になっていますが、端末の設定で通知を許可するまで動作しません。 + プッシュ通知は有効になっていますが、許可するまで機能しません 通知を許可する 製品ニュース、限定オファー、アクティビティのリマインダー。 オファー・最新情報 @@ -1827,6 +1829,10 @@ 日常の支払いにUSDCを利用 Tangem Payは現在一時的に利用できません。 Tangem Pay + USDC Polygon をアカウントのアドレスに送信 + 別のウォレットまたは取引所から + 任意の資産を USDC Polygon にスワップ + Tangem ウォレットから Polygonネットワーク上のUSDC 下のボタンをクリックしてアクセスを復元してください 返金分はオンチェーンのPolygon残高には戻らず、出金にも利用できません。ただし、カード残高として残り、支払いに利用できます。 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index bceb22fb6c..de8f5ba626 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -403,6 +403,7 @@ Enviando Enviado O servidor não está disponível. Tente novamente mais tarde. + Sessão expirada Compartilhar Compartilhar link Mostrar menos @@ -1209,7 +1210,9 @@ Organizar tokens Desagrupar %s suporte + Conceder permissão As notificações push estão ativadas, mas só funcionarão depois que você as permitir nas configurações do seu dispositivo. + As notificações push estão ativadas, mas não funcionarão até que você conceda permissão. Permitir notificações Novidades sobre produtos, ofertas exclusivas e lembretes de atividades. Ofertas e atualizações @@ -1853,6 +1856,10 @@ Use USDC para pagamentos do dia a dia. O serviço Tangem Pay está temporariamente inacessível. Tangem Pay + Envie USDC Polygon para o endereço da sua conta + De outra carteira ou exchange + Troque qualquer ativo por USDC Polygon + Da sua Tangem Wallet USDC na rede Polygon Clique no botão abaixo para restaurar o acesso. Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras @@ -2369,6 +2376,10 @@ Oferta especial para o modo Yield APY x3 yield_apy_boost_block_activate + Ative seu bônus + Consulte o histórico de transações para obter detalhes. + Bônus do modo Yield pago + %1$s Faltam poucos dias para desbloquear seu bônus. Você tem direito a um aumento de APY por 30 dias. Aplicam-se os termos e condições. Saiba mais. Ative o Modo de Rendimento pela primeira vez e obtenha até 3 vezes mais rendimento nos seus primeiros 30 dias. Bônus de APR no primeiro mês diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 10e73a5a0a..0218005218 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -84,7 +84,7 @@ Обмен Перевод Добавить в портфель - Добавить токен + Добавить токены Сортировка и группировка Упорядочить токены Выберите сеть @@ -617,6 +617,7 @@ Провайдер Лучший курс Фиксированная ставка недоступна + Провайдер для обмена Лучший выбор Доступно до %s Доступно с %s @@ -780,7 +781,7 @@ Лимит маны Сеть Koinos использует Ману для оплаты комиссии сети. У вас есть %1$s/%2$s Mana Уровень маны - Добавить и настроить + Добавить и управлять Чтобы начать отслеживать свои криптоактивы и транзакции, добавьте токены Управление токенами Отсканируйте QR-код, чтобы отправить средства или подключиться к приложению. @@ -1160,15 +1161,26 @@ Эта транзакция уже была обработана. Дополнительных действий не требуется. Получение лучших курсов... Моментально + Верификация бесплатная и обычно занимает 1-2 минуты + Tangem не будет иметь доступа к вашим личным данным, вы передаете их напрямую лицензированному провайдеру + Выберите другой метод + Согласно требованиям законодательства, %@ требует пройти верификацию личности. + Верифицировать + Что важно знать Пользуясь сервисом покупки, вы соглашаетесь с %1$s и %2$s Сумма покупки не может быть больше, чем %s Сумма покупки должна составлять минимум %s + Общая сумма транзакций свыше %1s может потребовать верификации личности через %2s + Общая сумма транзакций, превышающая эквивалент %1s, может потребовать верификации личности через %2s + Нажимая «Оплатить», вы соглашаетесь с %1s\'s %2s и %3s. Нет доступных провайдеров для выбранной валюты Самый быстрый Оплата с Платежный метод Доступно до %s Доступно от %s + Карты, выпущенные в США и Великобритании, не могут быть обработаны этим методом. Провайдер может запросить дополнительную верификацию личности + Требования провайдера %d провайдер %d провайдера @@ -1588,6 +1600,7 @@ Для отправки требуется входящая транзакция на сумму не менее %1$s Недостаточно средств Подтверждая, вы разрешаете смарт-контракту использовать ваши токены в будущих транзакциях. + Детальный режим Фиксированный курс Комиссия сети за одобрение токена будет взиматься за подтверждение того, что именно вы разрешаете использовать ваш токен для обмена. Обмен в процессе @@ -1595,6 +1608,7 @@ Новый провайдер обмена! Найдите любой токен, даже если его ещё нет в вашем списке Используйте поиск, чтобы найти то, что вам нужно. + Простой режим Чувствуйте уверенность с круглосуточной поддержкой, готовой помочь в любой ситуации! Круглосуточная поддержка Надежные провайдеры в одном месте — обменивайте любые активы легко и быстро прямо в своем кошельке! @@ -1632,6 +1646,10 @@ Недостаточно средств Недостаточно средств для завершения этой транзакции. Уменьшите сумму для получения или добавьте больше средств. Дать разрешение + Оцените ваш опыт взаимодействия с провайдером + Напишите ваш отзыв + Отправить отзыв + Что повлияло на вашу оценку? Обменять Обмен… Вы получите на @@ -1803,6 +1821,10 @@ Оплачивайте ежедневные покупки в USDC Tangem Pay временно недоступен Tangem Pay + Отправьте USDC Polygon на адрес вашего аккаунта + С другого кошелька или биржи + Обменяйте любой актив на USDC Polygon + Из вашего кошелька Tangem USDC в сети Polygon Нажмите на кнопку ниже, чтобы восстановить доступ При возвратах покупок средства не возвращаются на ончейн-баланс Polygon и недоступны для вывода, но отображаются на карте и могут быть использованы для покупок diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index fc2bbcf1f6..e8d7f2b393 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -80,6 +80,7 @@ Додайте токени Оберіть токен для отримання Оберіть токен для обміну + Додати токени Оберіть мережу Додати токен Токени @@ -569,6 +570,7 @@ Провайдер Найкращий курс Список попереджень FCA + Провайдер для обміну Найкращий вибір Список попереджень FCA Доступно до %s @@ -731,6 +733,7 @@ Ліміт Mana Мережа Koinos вимагає Mana для мережевої комісії. У вас є %1$s/%2$s Mana Рівень Mana + Додати та керувати Щоб почати відстежувати свої криптоактиви та транзакції, додайте токени Керування токенами Для доступу до всіх мереж необхідно відсканувати картку @@ -1092,16 +1095,27 @@ Ця транзакція вже була оброблена. Додаткові дії не потребуються. Шукаємо найвигідніший курс... Миттєво + Верифікація безкоштовна і зазвичай займає 1-2 хвилини + Tangem не матиме доступу до ваших особистих даних, ви передаєте їх безпосередньо ліцензованому провайдеру + Виберіть інший метод + Згідно з вимогами законодавства, %@ вимагає пройти верифікацію особи. + Верифікувати + Що важливо знати Використовуючи сервіс покупки, ви погоджуєтесь з %1$s та %2$s Послуга надається зовнішнім провайдером.\nTangem не несе відповідальності. Сума покупки не може бути більше ніж %s Сума покупки повинна бути не менше %s + Загальна сума транзакцій понад %1s може вимагати верифікації особи через %2s + Загальна сума транзакцій, що перевищує еквівалент %1s, може вимагати верифікації особи через %2s + Натискаючи «Оплатити», ви погоджуєтеся з %1s\'s %2s і %3s. Для данної валюти немає доступних провайдерів Найшвидший Оплата з Спосіб оплати Доступно до %s Доступно від %s + Картки, випущені у США та Великій Британії, не можуть бути оброблені цим методом. Провайдер може запросити додаткову верифікацію особи + Вимоги провайдера %d провайдер %d провайдери @@ -1509,6 +1523,7 @@ Для відправки потрібна вхідна транзакція на суму не менше %1$s Недостатньо коштів Підтверджуючи, ви дозволяєте смартконтракту використовувати ваші токени в майбутніх транзакціях. + Детальний режим Фіксований курс Мережа стягує комісію за схвалення токену за підтвердження, що саме ви дозволяєте використовувати ваш токен для обміну. Обмін у процесі @@ -1517,6 +1532,7 @@ Шукаєте щось інше?\nСпробуйте пошукати або перегляньте інші криптовалюти! Шукайте будь-який токен, навіть якщо його ще немає у вашому списку. Використовуйте пошук, щоб знайти потрібне + Простий режим Надійна підтримка 24/7, щоб ваші фінансові операції були швидкими та надійними! Цілодобова підтримка Надійні провайдери дозволяють легко обмінювати активи, забезпечуючи повну безпеку у вашому гаманці @@ -1551,6 +1567,10 @@ Високий вплив на ціну Недостатньо коштів Надати дозвіл + Оцініть ваш досвід взаємодії з провайдером + Напишіть ваш відгук + Надіслати відгук + Що вплинуло на вашу оцінку? Обміняти Обмін... Ви отримаєте на @@ -1720,6 +1740,10 @@ Використовуйте USDC для щоденних платежів Tangem Pay тимчасово недоступний Tangem Pay + Надішліть USDC Polygon на адресу вашого акаунту + З іншого гаманця або біржі + Обміняйте будь-який актив на USDC Polygon + З вашого Tangem Wallet USDC у Polygon Натисніть кнопку нижче, щоб відновити доступ Кошти з повернених покупок не будуть повернуті на ваш ончейн-баланс Polygon і не будуть доступні для виведення, але залишаться на балансі картки для покупок. diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 035a3d3260..9af010f72a 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1187,7 +1187,9 @@ 整理代币 取消分组 %s 支持 + 授予权限 推送通知已启用,但需要您在设备设置中允许通知才能正常工作。 + 推送通知已启用,但需要您授予权限才能生效。 允许通知 产品资讯、独家优惠和活动提醒。 优惠与更新 @@ -1827,6 +1829,10 @@ 使用 USDC 进行日常支付 Tangem Pay暂时无法使用。 Tangem Pay + 將 USDC Polygon 發送至您帳戶地址 + 從其他錢包或交易所 + 將任何資產兌換為 USDC Polygon + 從您的 Tangem 錢包 Polygon网络上的 USDC 点击下方按钮恢复访问权限 您的Polygon链上 USDC 余额与您的卡片余额不同,并在购买后 2 个工作日内更新。购物退款的资金不会退还至您的链上余额,也不能提现,但会保留在您的卡片余额中用于购物。 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index cd7b619b41..7f24d3f492 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -435,6 +435,10 @@ 使用 USDC 進行日常支付 Tangem Pay暂时不可用 Tangem Pay + 将 USDC Polygon 发送至您账户地址 + 从其他钱包或交易所 + 将任何资产兑换为 USDC Polygon + 从您的 Tangem 钱包 點擊下方按鈕以恢復存取權限 您的 USDC Polygon 鏈上餘額與卡片餘額不同,並在購買後 2 個工作日內更新。退款交易的資金不會返回到您的鏈上餘額或可供提現,但會保留在您的卡片餘額中用於購買。 請注意 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 8123702885..aecdc07427 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 @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.Dp import com.tangem.core.ui.R import com.tangem.core.ui.components.* import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import androidx.compose.ui.text.AnnotatedString import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference @@ -259,7 +260,9 @@ internal fun TextsBlock( titleColor: Color = TangemTheme.colors.text.primary1, ) { Column(modifier = modifier) { - val titleText = title?.resolveReference() + val titleText = title?.let { ref -> + if (ref is TextReference.Annotated) ref.value else AnnotatedString(ref.resolveReference()) + } if (titleText != null) { Text( diff --git a/core/ui/src/main/res/drawable/ic_gift_promo_24.xml b/core/ui/src/main/res/drawable/ic_gift_promo_24.xml new file mode 100644 index 0000000000..b47ab7372f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_gift_promo_24.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/data/yield-supply/build.gradle.kts b/data/yield-supply/build.gradle.kts index d5edaba7cd..c5f7190f46 100644 --- a/data/yield-supply/build.gradle.kts +++ b/data/yield-supply/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { kapt(deps.hilt.kapt) /** Other */ + implementation(deps.kotlin.datetime) /** tests */ testImplementation(projects.common.test) diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt index ee040a543a..c432bde85a 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -4,13 +4,18 @@ import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.data.yield.supply.DefaultYieldSupplyRepository import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository +import com.tangem.data.yield.supply.promo.DefaultYieldPromoRepository +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.YieldSupplyApi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.YieldSupplyErrorResolver import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository +import com.tangem.domain.yield.supply.promo.YieldPromoRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -59,4 +64,20 @@ internal object YieldSupplyDataModule { fun provideYieldSupplyErrorResolver(): YieldSupplyErrorResolver { return DefaultYieldSupplyErrorResolver } + + @Provides + @Singleton + fun provideYieldPromoRepository( + tangemApi: TangemTechApi, + promoStore: YieldBoostPromoStore, + statusStore: YieldBoostStatusStore, + dispatchers: CoroutineDispatcherProvider, + ): YieldPromoRepository { + return DefaultYieldPromoRepository( + tangemApi = tangemApi, + promoStore = promoStore, + statusStore = statusStore, + dispatchers = dispatchers, + ) + } } \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt new file mode 100644 index 0000000000..f9463dd69e --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepository.kt @@ -0,0 +1,63 @@ +package com.tangem.data.yield.supply.promo + +import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter +import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.domain.yield.supply.promo.YieldPromoRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +internal class DefaultYieldPromoRepository( + private val tangemApi: TangemTechApi, + private val promoStore: YieldBoostPromoStore, + private val statusStore: YieldBoostStatusStore, + private val dispatchers: CoroutineDispatcherProvider, +) : YieldPromoRepository { + + override suspend fun getYieldBoostPromo(userWalletId: UserWalletId, forceRefresh: Boolean): YieldBoostPromo { + if (!forceRefresh) { + promoStore.getSyncOrNull(userWalletId)?.let { return it } + } + return try { + val fresh = fetchPromo(userWalletId) + promoStore.store(userWalletId, fresh) + fresh + } catch (e: Exception) { + promoStore.getSyncOrNull(userWalletId) ?: throw e + } + } + + override suspend fun getYieldBoostStatus(userWalletId: UserWalletId, forceRefresh: Boolean): YieldBoostStatus { + if (!forceRefresh) { + statusStore.getSyncOrNull(userWalletId)?.let { return it } + } + return try { + val fresh = fetchStatus(userWalletId) + statusStore.store(userWalletId, fresh) + fresh + } catch (e: Exception) { + statusStore.getSyncOrNull(userWalletId) ?: throw e + } + } + + private suspend fun fetchPromo(userWalletId: UserWalletId): YieldBoostPromo = withContext(dispatchers.io) { + val response = tangemApi.getPromotions(walletId = userWalletId.stringValue).getOrThrow() + val dto = response.promotions.firstOrNull { it.name == PROMO_NAME } ?: return@withContext YieldBoostPromo.None + YieldBoostPromoConverter.convert(dto) + } + + private suspend fun fetchStatus(userWalletId: UserWalletId): YieldBoostStatus = withContext(dispatchers.io) { + val response = tangemApi.getYieldBoostStatus(walletId = userWalletId.stringValue).getOrThrow() + YieldBoostStatusConverter.convert(response) + } + + private companion object { + const val PROMO_NAME = "yield-apr-boost" + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverter.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverter.kt new file mode 100644 index 0000000000..58b409dee1 --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverter.kt @@ -0,0 +1,34 @@ +package com.tangem.data.yield.supply.promo.converter + +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import kotlinx.datetime.Instant + +internal object YieldBoostPromoConverter { + + private const val ACTIVE_STATUS = "active" + + fun convert(dto: PromotionsResponse.PromotionDto): YieldBoostPromo { + val all = dto.all ?: return YieldBoostPromo.None + if (!all.status.equals(ACTIVE_STATUS, ignoreCase = true)) return YieldBoostPromo.None + + val start = runCatching { Instant.parse(all.timeline.start) }.getOrNull() ?: return YieldBoostPromo.None + val end = runCatching { Instant.parse(all.timeline.end) }.getOrNull() ?: return YieldBoostPromo.None + + val tokens = all.tokens.orEmpty().map { token -> + YieldBoostPromo.Active.PromoToken( + contractAddress = token.tokenAddress, + tokenSymbol = token.tokenSymbol, + tokenName = token.tokenName, + networkId = token.networkId, + ) + } + if (tokens.isEmpty()) return YieldBoostPromo.None + + return YieldBoostPromo.Active( + tokens = tokens, + timeline = YieldBoostPromo.Active.Timeline(start = start, end = end), + link = all.link, + ) + } +} \ No newline at end of file diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverter.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverter.kt new file mode 100644 index 0000000000..4ddbac4301 --- /dev/null +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverter.kt @@ -0,0 +1,63 @@ +package com.tangem.data.yield.supply.promo.converter + +import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import kotlinx.datetime.Instant + +internal object YieldBoostStatusConverter { + + private const val STATUS_NOT_STARTED = "notstarted" + private const val STATUS_ACTIVE = "active" + private const val STATUS_COMPLETED = "completed" + private const val STATUS_DISQUALIFIED = "disqualified" + + private const val REASON_FROD = "frod" + private const val REASON_LESS_THAN_1_USD = "less1usd" + private const val REASON_CLOSED = "closed" + + fun convert(dto: YieldBoostStatusResponse): YieldBoostStatus = when (dto.promoEnrollmentStatus.lowercase()) { + STATUS_ACTIVE -> dto.toActive() ?: YieldBoostStatus.NotStarted + STATUS_COMPLETED -> dto.toCompleted() ?: YieldBoostStatus.NotStarted + STATUS_DISQUALIFIED -> YieldBoostStatus.Disqualified(reason = dto.disqualificationReason.toReason()) + STATUS_NOT_STARTED -> YieldBoostStatus.NotStarted + else -> YieldBoostStatus.NotStarted // forward-compat: unknown status → treat as NotStarted + } + + /** Backend `"active"` → [YieldBoostStatus.Active]. Returns `null` if mandatory dates can't be parsed. */ + private fun YieldBoostStatusResponse.toActive(): YieldBoostStatus.Active? { + val activation = activationDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null + val qualificationEnd = + qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null + return YieldBoostStatus.Active( + tokenName = tokenName.orEmpty(), + networkId = networkId.orEmpty(), + moduleAddress = moduleAddress.orEmpty(), + userAddress = userAddress.orEmpty(), + contractAddress = contractAddress.orEmpty(), + activationDate = activation, + qualificationEndDate = qualificationEnd, + ) + } + + private fun YieldBoostStatusResponse.toCompleted(): YieldBoostStatus.Completed? { + val activation = activationDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null + val qualificationEnd = + qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null + return YieldBoostStatus.Completed( + tokenName = tokenName.orEmpty(), + networkId = networkId.orEmpty(), + moduleAddress = moduleAddress.orEmpty(), + userAddress = userAddress.orEmpty(), + contractAddress = contractAddress.orEmpty(), + activationDate = activation, + qualificationEndDate = qualificationEnd, + ) + } + + private fun String?.toReason(): YieldBoostStatus.Disqualified.Reason = when (this?.lowercase()) { + REASON_FROD -> YieldBoostStatus.Disqualified.Reason.FROD + REASON_LESS_THAN_1_USD -> YieldBoostStatus.Disqualified.Reason.LESS_THAN_1_USD + REASON_CLOSED -> YieldBoostStatus.Disqualified.Reason.CLOSED + else -> YieldBoostStatus.Disqualified.Reason.UNKNOWN + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt new file mode 100644 index 0000000000..8d1c034fba --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostPromoConverterTest.kt @@ -0,0 +1,119 @@ +package com.tangem.data.yield.supply.promo.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import org.junit.jupiter.api.Test + +class YieldBoostPromoConverterTest { + + @Test + fun `GIVEN active dto with tokens WHEN convert THEN returns Active`() { + val dto = activeDto() + + val result = YieldBoostPromoConverter.convert(dto) + + assertThat(result).isInstanceOf(YieldBoostPromo.Active::class.java) + val active = result as YieldBoostPromo.Active + assertThat(active.tokens).hasSize(2) + assertThat(active.tokens.first().contractAddress) + .isEqualTo("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + assertThat(active.tokens.first().networkId).isEqualTo("ethereum") + assertThat(active.link).isEqualTo("https://example.com/terms") + } + + @Test + fun `GIVEN dto with null all WHEN convert THEN returns None`() { + val dto = PromotionsResponse.PromotionDto(name = "promo-yield-apr-boost", all = null) + + val result = YieldBoostPromoConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostPromo.None) + } + + @Test + fun `GIVEN dto with non-active status WHEN convert THEN returns None`() { + val dto = activeDto().copy( + all = activeDto().all!!.copy(status = "expired"), + ) + + val result = YieldBoostPromoConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostPromo.None) + } + + @Test + fun `GIVEN dto with empty tokens WHEN convert THEN returns None`() { + val dto = activeDto().copy( + all = activeDto().all!!.copy(tokens = emptyList()), + ) + + val result = YieldBoostPromoConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostPromo.None) + } + + @Test + fun `GIVEN dto with null tokens WHEN convert THEN returns None`() { + val dto = activeDto().copy( + all = activeDto().all!!.copy(tokens = null), + ) + + val result = YieldBoostPromoConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostPromo.None) + } + + @Test + fun `GIVEN dto with malformed start date WHEN convert THEN returns None`() { + val dto = activeDto().copy( + all = activeDto().all!!.copy( + timeline = PromotionsResponse.PromotionDto.Timeline( + start = "not-an-iso", + end = "2027-06-15T22:00:00.000Z", + ), + ), + ) + + val result = YieldBoostPromoConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostPromo.None) + } + + @Test + fun `GIVEN status with uppercase casing WHEN convert THEN treats as active`() { + val dto = activeDto().copy( + all = activeDto().all!!.copy(status = "ACTIVE"), + ) + + val result = YieldBoostPromoConverter.convert(dto) + + assertThat(result).isInstanceOf(YieldBoostPromo.Active::class.java) + } + + private fun activeDto() = PromotionsResponse.PromotionDto( + name = "promo-yield-apr-boost", + all = PromotionsResponse.PromotionDto.All( + timeline = PromotionsResponse.PromotionDto.Timeline( + start = "2026-06-15T00:00:00.000Z", + end = "2027-06-15T22:00:00.000Z", + ), + tokens = listOf( + PromotionsResponse.PromotionDto.PromoToken( + tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + tokenSymbol = "USDC", + tokenName = "USD Coin", + networkId = "ethereum", + ), + PromotionsResponse.PromotionDto.PromoToken( + tokenAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7", + tokenSymbol = "USDT", + tokenName = "Tether USD", + networkId = "ethereum", + ), + ), + status = "active", + link = "https://example.com/terms", + ), + ) +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverterTest.kt new file mode 100644 index 0000000000..3e9c7c3850 --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/converter/YieldBoostStatusConverterTest.kt @@ -0,0 +1,186 @@ +package com.tangem.data.yield.supply.promo.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import org.junit.jupiter.api.Test + +class YieldBoostStatusConverterTest { + + private val activation = "2026-05-01T00:00:00Z" + private val qualificationEnd = "2026-06-01T00:00:00Z" + + @Test + fun `GIVEN promoEnrollmentStatus notStarted WHEN convert THEN returns NotStarted`() { + val dto = dto(promoEnrollmentStatus = "notStarted") + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostStatus.NotStarted) + } + + @Test + fun `GIVEN active backend status with valid dates WHEN convert THEN returns Active`() { + val dto = dto( + promoEnrollmentStatus = "active", + tokenName = "USD Coin", + networkId = "ethereum", + moduleAddress = "0xmodule", + userAddress = "0xuser", + contractAddress = "0xcontract", + activationDate = activation, + qualificationEndDate = qualificationEnd, + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isInstanceOf(YieldBoostStatus.Active::class.java) + val active = result as YieldBoostStatus.Active + assertThat(active.tokenName).isEqualTo("USD Coin") + assertThat(active.networkId).isEqualTo("ethereum") + assertThat(active.contractAddress).isEqualTo("0xcontract") + } + + @Test + fun `GIVEN active status missing activationDate WHEN convert THEN falls back to NotStarted`() { + val dto = dto( + promoEnrollmentStatus = "active", + activationDate = null, + qualificationEndDate = qualificationEnd, + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostStatus.NotStarted) + } + + @Test + fun `GIVEN active status with malformed activationDate WHEN convert THEN falls back to NotStarted`() { + val dto = dto( + promoEnrollmentStatus = "active", + activationDate = "not-an-iso", + qualificationEndDate = qualificationEnd, + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostStatus.NotStarted) + } + + @Test + fun `GIVEN completed status with valid dates WHEN convert THEN returns Completed`() { + val dto = dto( + promoEnrollmentStatus = "completed", + tokenName = "USDT", + networkId = "ethereum", + moduleAddress = "0xmodule", + userAddress = "0xuser", + contractAddress = "0xcontract", + activationDate = "2026-04-01T00:00:00Z", + qualificationEndDate = "2026-05-01T00:00:00Z", + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isInstanceOf(YieldBoostStatus.Completed::class.java) + } + + @Test + fun `GIVEN disqualified frod reason WHEN convert THEN returns Disqualified with FROD reason`() { + val dto = dto( + promoEnrollmentStatus = "disqualified", + disqualificationReason = "frod", + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.FROD)) + } + + @Test + fun `GIVEN disqualified less1usd reason WHEN convert THEN returns Disqualified with LESS_THAN_1_USD reason`() { + val dto = dto( + promoEnrollmentStatus = "disqualified", + disqualificationReason = "less1usd", + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isEqualTo( + YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.LESS_THAN_1_USD), + ) + } + + @Test + fun `GIVEN disqualified closed reason WHEN convert THEN returns Disqualified with CLOSED reason`() { + val dto = dto( + promoEnrollmentStatus = "disqualified", + disqualificationReason = "closed", + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.CLOSED)) + } + + @Test + fun `GIVEN disqualified unknown reason WHEN convert THEN returns Disqualified with UNKNOWN reason`() { + val dto = dto( + promoEnrollmentStatus = "disqualified", + disqualificationReason = "alien_invasion", + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.UNKNOWN)) + } + + @Test + fun `GIVEN unknown promoEnrollmentStatus WHEN convert THEN returns NotStarted`() { + val dto = dto(promoEnrollmentStatus = "futureBackendStatus") + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isEqualTo(YieldBoostStatus.NotStarted) + } + + @Test + fun `GIVEN status with uppercase casing WHEN convert THEN normalizes correctly`() { + val dto = dto( + promoEnrollmentStatus = "ACTIVE", + tokenName = "USDT", + networkId = "ethereum", + moduleAddress = "0xmodule", + userAddress = "0xuser", + contractAddress = "0xcontract", + activationDate = activation, + qualificationEndDate = qualificationEnd, + ) + + val result = YieldBoostStatusConverter.convert(dto) + + assertThat(result).isInstanceOf(YieldBoostStatus.Active::class.java) + } + + private fun dto( + promoEnrollmentStatus: String, + tokenName: String? = null, + networkId: String? = null, + moduleAddress: String? = null, + userAddress: String? = null, + contractAddress: String? = null, + activationDate: String? = null, + qualificationEndDate: String? = null, + disqualificationReason: String? = null, + ) = YieldBoostStatusResponse( + tokenName = tokenName, + networkId = networkId, + moduleAddress = moduleAddress, + userAddress = userAddress, + contractAddress = contractAddress, + promoEnrollmentStatus = promoEnrollmentStatus, + activationDate = activationDate, + qualificationEndDate = qualificationEndDate, + disqualificationReason = disqualificationReason, + ) +} \ No newline at end of file diff --git a/domain/stories/models/src/main/java/com/tangem/domain/stories/models/StoryContent.kt b/domain/stories/models/src/main/java/com/tangem/domain/stories/models/StoryContent.kt index c6ed1ec662..4b6cecb5cb 100644 --- a/domain/stories/models/src/main/java/com/tangem/domain/stories/models/StoryContent.kt +++ b/domain/stories/models/src/main/java/com/tangem/domain/stories/models/StoryContent.kt @@ -25,4 +25,5 @@ data class StoryContent( enum class StoryContentIds(val id: String, val analyticType: String) { STORY_FIRST_TIME_SWAP(id = "first-time-swap-v2", analyticType = "Swap"), + STORY_FIRST_TIME_YIELD_PROMO(id = "first-time-yield-promo", analyticType = "YieldPromo"), } \ No newline at end of file diff --git a/domain/yield-supply/build.gradle.kts b/domain/yield-supply/build.gradle.kts index 86c16fe968..a5e443e7c3 100644 --- a/domain/yield-supply/build.gradle.kts +++ b/domain/yield-supply/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.utils) implementation(projects.libs.blockchainSdk) + implementation(projects.libs.crypto) /** Domain */ implementation(projects.domain.account.status) diff --git a/domain/yield-supply/models/build.gradle.kts b/domain/yield-supply/models/build.gradle.kts index 83fcc0276e..faf3c29745 100644 --- a/domain/yield-supply/models/build.gradle.kts +++ b/domain/yield-supply/models/build.gradle.kts @@ -11,5 +11,6 @@ dependencies { // region Other libraries implementation(deps.kotlin.serialization) + api(deps.kotlin.datetime) } diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostPromo.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostPromo.kt new file mode 100644 index 0000000000..df8e931397 --- /dev/null +++ b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostPromo.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.yield.supply.models + +import kotlinx.datetime.Instant + +sealed interface YieldBoostPromo { + + data object None : YieldBoostPromo + + data class Active( + val tokens: List, + val timeline: Timeline, + val link: String?, + ) : YieldBoostPromo { + + data class PromoToken( + val contractAddress: String, + val tokenSymbol: String, + val tokenName: String, + val networkId: String, + ) + + data class Timeline(val start: Instant, val end: Instant) + } +} \ No newline at end of file diff --git a/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostStatus.kt b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostStatus.kt new file mode 100644 index 0000000000..383e27cef2 --- /dev/null +++ b/domain/yield-supply/models/src/main/java/com/tangem/domain/yield/supply/models/YieldBoostStatus.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.yield.supply.models + +import kotlinx.datetime.Instant + +sealed interface YieldBoostStatus { + + data object NotStarted : YieldBoostStatus + + /** User entered boost, qualification period is still running. */ + data class Active( + val tokenName: String, + val networkId: String, + val moduleAddress: String, + val userAddress: String, + val contractAddress: String, + val activationDate: Instant, + val qualificationEndDate: Instant, + ) : YieldBoostStatus + + /** Boost has finished (backend `completed`). */ + data class Completed( + val tokenName: String, + val networkId: String, + val moduleAddress: String, + val userAddress: String, + val contractAddress: String, + val activationDate: Instant, + val qualificationEndDate: Instant, + ) : YieldBoostStatus + + data class Disqualified(val reason: Reason) : YieldBoostStatus { + + enum class Reason { FROD, LESS_THAN_1_USD, CLOSED, UNKNOWN } + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/YieldPromoRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/YieldPromoRepository.kt new file mode 100644 index 0000000000..c23ed51da7 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/YieldPromoRepository.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.yield.supply.promo + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import com.tangem.domain.yield.supply.models.YieldBoostStatus + +/** + * Backend yield-boost promo plumbing. + * + * Implementations keep an in-memory cache keyed by [UserWalletId]. On a refresh failure the cached + * value is returned. With an empty cache the call throws — use cases swallow that to "hide UI". + */ +interface YieldPromoRepository { + + @Throws + suspend fun getYieldBoostPromo(userWalletId: UserWalletId, forceRefresh: Boolean = false): YieldBoostPromo + + @Throws + suspend fun getYieldBoostStatus(userWalletId: UserWalletId, forceRefresh: Boolean = false): YieldBoostStatus +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/GetBoostedApyUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/GetBoostedApyUseCase.kt new file mode 100644 index 0000000000..da12287edd --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/GetBoostedApyUseCase.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.yield.supply.promo.usecase + +import java.math.BigDecimal + +/** + * Pure boosted APY calculation. Hard-coded x3 coefficient — single place to swap when the backend + * starts returning the coefficient explicitly. + */ +class GetBoostedApyUseCase { + + operator fun invoke(baseApy: BigDecimal): BigDecimal = baseApy.multiply(BOOST_MULTIPLIER) + + private companion object { + val BOOST_MULTIPLIER: BigDecimal = BigDecimal(3) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/GetYieldBoostStatusUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/GetYieldBoostStatusUseCase.kt new file mode 100644 index 0000000000..fedd4e55d3 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/GetYieldBoostStatusUseCase.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.yield.supply.promo.usecase + +import arrow.core.Either +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.domain.yield.supply.promo.YieldPromoRepository + +class GetYieldBoostStatusUseCase( + private val repository: YieldPromoRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + forceRefresh: Boolean = false, + ): Either = Either.catch { + repository.getYieldBoostStatus(userWalletId, forceRefresh) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCase.kt new file mode 100644 index 0000000000..1350b03cfc --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCase.kt @@ -0,0 +1,47 @@ +package com.tangem.domain.yield.supply.promo.usecase + +import arrow.core.Either +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.domain.yield.supply.promo.YieldPromoRepository +import com.tangem.lib.crypto.BlockchainUtils + +/** + * Returns `true` iff the given token is in the active promo list AND the user has not started boost yet. + * + * Short-circuits to `false` on: + * - non-Token currency + * - promo `None` (no active promo) + * - status not `NotStarted` (already Active / Completed / Disqualified) + * + * Any underlying repository failure surfaces as `Either.Left`. + * + * Feature-toggle and redesign-flag gating is the caller's responsibility — keep this use case + * decoupled from feature-layer toggles to avoid the cyclic dependency `domain -> features`. + */ +class IsYieldBoostPromoEnabledForTokenUseCase( + private val repository: YieldPromoRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Either = Either.catch { + val token = cryptoCurrency as? CryptoCurrency.Token ?: return@catch false + + val promo = repository.getYieldBoostPromo(userWalletId) + if (promo !is YieldBoostPromo.Active) return@catch false + + val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId) + val isTokenMatched = promo.tokens.any { promoToken -> + promoToken.contractAddress.equals(token.contractAddress, ignoreCase = shouldIgnoreCase) && + promoToken.networkId == token.network.rawId + } + if (!isTokenMatched) return@catch false + + val status = repository.getYieldBoostStatus(userWalletId) + status is YieldBoostStatus.NotStarted + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCase.kt new file mode 100644 index 0000000000..b96734d0b5 --- /dev/null +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCase.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.yield.supply.promo.usecase + +import arrow.core.Either +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.domain.yield.supply.promo.YieldPromoRepository + +/** + * Returns `true` iff the main wallet boost banner should be shown: + * - promo is `Active` server-side + * - status is `NotStarted` + * + * Token ownership is intentionally NOT checked — the banner is shown to every eligible wallet + * regardless of whether it currently holds a promo token. + * + * Any repository failure surfaces as `Either.Left` — never assume eligibility on uncertainty. + * Feature-toggle / redesign / "user dismissed" gating is the caller's responsibility. + */ +class ShouldShowYieldBoostMainBannerUseCase( + private val repository: YieldPromoRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either = Either.catch { + val promo = repository.getYieldBoostPromo(userWalletId) + if (promo !is YieldBoostPromo.Active) return@catch false + + val status = repository.getYieldBoostStatus(userWalletId) + status is YieldBoostStatus.NotStarted + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/GetBoostedApyUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/GetBoostedApyUseCaseTest.kt new file mode 100644 index 0000000000..22ba317892 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/GetBoostedApyUseCaseTest.kt @@ -0,0 +1,38 @@ +package com.tangem.domain.yield.supply.promo.usecase + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class GetBoostedApyUseCaseTest { + + private val useCase = GetBoostedApyUseCase() + + @Test + fun `GIVEN base apy 5_1 WHEN invoke THEN returns 15_3`() { + val result = useCase(BigDecimal("5.1")) + + assertThat(result).isEqualTo(BigDecimal("15.3")) + } + + @Test + fun `GIVEN base apy 0 WHEN invoke THEN returns 0`() { + val result = useCase(BigDecimal.ZERO) + + assertThat(result).isEqualTo(BigDecimal.ZERO.multiply(BigDecimal(3))) + } + + @Test + fun `GIVEN base apy 4_99 WHEN invoke THEN returns 14_97`() { + val result = useCase(BigDecimal("4.99")) + + assertThat(result).isEqualTo(BigDecimal("14.97")) + } + + @Test + fun `GIVEN base apy 100 WHEN invoke THEN returns 300`() { + val result = useCase(BigDecimal("100")) + + assertThat(result).isEqualTo(BigDecimal("300")) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt new file mode 100644 index 0000000000..9ba69c2fea --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt @@ -0,0 +1,246 @@ +package com.tangem.domain.yield.supply.promo.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.domain.yield.supply.promo.YieldPromoRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class IsYieldBoostPromoEnabledForTokenUseCaseTest { + + private val repository: YieldPromoRepository = mockk() + private lateinit var useCase: IsYieldBoostPromoEnabledForTokenUseCase + + private val userWalletId = UserWalletId("abcdef012345") + private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + private val networkRawId = "ethereum" + + @BeforeEach + fun setUp() { + useCase = IsYieldBoostPromoEnabledForTokenUseCase(repository = repository) + } + + @Test + fun `GIVEN currency is coin WHEN invoke THEN returns Right(false)`() = runTest { + val coin = createCoin() + + val result = useCase(userWalletId, coin) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN promo is None WHEN invoke THEN returns Right(false)`() = runTest { + val token = createToken() + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns YieldBoostPromo.None + + val result = useCase(userWalletId, token) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN promo repository throws WHEN invoke THEN returns Left`() = runTest { + val token = createToken() + coEvery { repository.getYieldBoostPromo(userWalletId, false) } throws RuntimeException("net") + + val result = useCase(userWalletId, token) + + assertThat(result.isLeft()).isTrue() + } + + @Test + fun `GIVEN token not in promo list WHEN invoke THEN returns Right(false)`() = runTest { + val token = createToken(contractAddress = "0xdifferent") + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + + val result = useCase(userWalletId, token) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN network mismatch WHEN invoke THEN returns Right(false)`() = runTest { + val token = createToken(networkRawId = "polygon") + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + + val result = useCase(userWalletId, token) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN status repository throws WHEN invoke THEN returns Left`() = runTest { + val token = createToken() + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } throws RuntimeException("net") + + val result = useCase(userWalletId, token) + + assertThat(result.isLeft()).isTrue() + } + + @Test + fun `GIVEN status is Active WHEN invoke THEN returns Right(false)`() = runTest { + val token = createToken() + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns activeStatus() + + val result = useCase(userWalletId, token) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN status is Completed WHEN invoke THEN returns Right(false)`() = runTest { + val token = createToken() + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns completedStatus() + + val result = useCase(userWalletId, token) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN status is Disqualified WHEN invoke THEN returns Right(false)`() = runTest { + val token = createToken() + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns + YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.FROD) + + val result = useCase(userWalletId, token) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN status is NotStarted and token matches WHEN invoke THEN returns Right(true)`() = runTest { + val token = createToken() + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns YieldBoostStatus.NotStarted + + val result = useCase(userWalletId, token) + + assertThat(result.getOrNull()).isTrue() + } + + @Test + fun `GIVEN contract address differs only in case on EVM WHEN invoke THEN returns Right(true)`() = runTest { + val token = createToken(contractAddress = contractAddress.uppercase()) + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns YieldBoostStatus.NotStarted + + val result = useCase(userWalletId, token) + + assertThat(result.getOrNull()).isTrue() + } + + private fun activePromo() = YieldBoostPromo.Active( + tokens = listOf( + YieldBoostPromo.Active.PromoToken( + contractAddress = contractAddress, + tokenSymbol = "USDC", + tokenName = "USD Coin", + networkId = networkRawId, + ), + ), + timeline = YieldBoostPromo.Active.Timeline( + start = Instant.parse("2026-01-01T00:00:00Z"), + end = Instant.parse("2027-01-01T00:00:00Z"), + ), + link = null, + ) + + private fun activeStatus() = YieldBoostStatus.Active( + tokenName = "USD Coin", + networkId = networkRawId, + moduleAddress = "0xmodule", + userAddress = "0xuser", + contractAddress = contractAddress, + activationDate = Instant.parse("2026-05-01T00:00:00Z"), + qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"), + ) + + private fun completedStatus() = YieldBoostStatus.Completed( + tokenName = "USD Coin", + networkId = networkRawId, + moduleAddress = "0xmodule", + userAddress = "0xuser", + contractAddress = contractAddress, + activationDate = Instant.parse("2026-04-01T00:00:00Z"), + qualificationEndDate = Instant.parse("2026-05-01T00:00:00Z"), + ) + + private fun createToken( + contractAddress: String = this.contractAddress, + networkRawId: String = this.networkRawId, + ): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = networkRawId, derivationPath = derivationPath), + name = networkRawId, + currencySymbol = networkRawId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(networkRawId), + suffix = CryptoCurrency.ID.Suffix.RawID(networkRawId), + ), + network = network, + name = "USDC", + symbol = "USDC", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + + private fun createCoin(): CryptoCurrency.Coin { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = networkRawId, derivationPath = derivationPath), + name = networkRawId, + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(networkRawId), + suffix = CryptoCurrency.ID.Suffix.RawID(networkRawId), + ), + network = network, + name = "Ethereum", + symbol = "ETH", + decimals = 18, + iconUrl = null, + isCustom = false, + ) + } +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt new file mode 100644 index 0000000000..9461882859 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt @@ -0,0 +1,104 @@ +package com.tangem.domain.yield.supply.promo.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.domain.yield.supply.promo.YieldPromoRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ShouldShowYieldBoostMainBannerUseCaseTest { + + private val repository: YieldPromoRepository = mockk() + private lateinit var useCase: ShouldShowYieldBoostMainBannerUseCase + + private val userWalletId = UserWalletId("abcdef012345") + private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + private val networkRawId = "ethereum" + + @BeforeEach + fun setUp() { + useCase = ShouldShowYieldBoostMainBannerUseCase(repository = repository) + } + + @Test + fun `GIVEN promo repository throws WHEN invoke THEN returns Left`() = runTest { + coEvery { repository.getYieldBoostPromo(userWalletId, false) } throws RuntimeException("net") + + val result = useCase(userWalletId) + + assertThat(result.isLeft()).isTrue() + } + + @Test + fun `GIVEN promo is None WHEN invoke THEN returns Right(false)`() = runTest { + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns YieldBoostPromo.None + + val result = useCase(userWalletId) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN status repository throws WHEN invoke THEN returns Left`() = runTest { + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } throws RuntimeException("net") + + val result = useCase(userWalletId) + + assertThat(result.isLeft()).isTrue() + } + + @Test + fun `GIVEN status is Active WHEN invoke THEN returns Right(false)`() = runTest { + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns activeStatus() + + val result = useCase(userWalletId) + + assertThat(result.getOrNull()).isFalse() + } + + @Test + fun `GIVEN promo Active and status NotStarted WHEN invoke THEN returns Right(true)`() = runTest { + coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo() + coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns YieldBoostStatus.NotStarted + + val result = useCase(userWalletId) + + assertThat(result.getOrNull()).isTrue() + } + + private fun activePromo() = YieldBoostPromo.Active( + tokens = listOf( + YieldBoostPromo.Active.PromoToken( + contractAddress = contractAddress, + tokenSymbol = "USDC", + tokenName = "USD Coin", + networkId = networkRawId, + ), + ), + timeline = YieldBoostPromo.Active.Timeline( + start = Instant.parse("2026-01-01T00:00:00Z"), + end = Instant.parse("2027-01-01T00:00:00Z"), + ), + link = null, + ) + + private fun activeStatus() = YieldBoostStatus.Active( + tokenName = "USD Coin", + networkId = networkRawId, + moduleAddress = "0xmodule", + userAddress = "0xuser", + contractAddress = contractAddress, + activationDate = Instant.parse("2026-05-01T00:00:00Z"), + qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"), + ) +} \ No newline at end of file diff --git a/features/stories/api/src/main/java/com/tangem/feature/stories/api/StoriesComponent.kt b/features/stories/api/src/main/java/com/tangem/feature/stories/api/StoriesComponent.kt index 3e29a1afe4..b6b37780cf 100644 --- a/features/stories/api/src/main/java/com/tangem/feature/stories/api/StoriesComponent.kt +++ b/features/stories/api/src/main/java/com/tangem/feature/stories/api/StoriesComponent.kt @@ -10,6 +10,7 @@ interface StoriesComponent : ComposableContentComponent { val storyId: String, val nextScreen: AppRoute? = null, val screenSource: String, + val shouldMarkAsSeenOnClose: Boolean = true, ) interface Factory : ComponentFactory diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt index 44bddacf3d..d6c0a89191 100644 --- a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt @@ -1,6 +1,7 @@ package com.tangem.feature.stories.impl import com.tangem.domain.stories.models.StoryContentIds +import com.tangem.core.res.R as CoreResR import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -13,6 +14,7 @@ internal object StoriesSlideConfigs { fun getSlides(storyId: String): ImmutableList = when (storyId) { StoryContentIds.STORY_FIRST_TIME_SWAP.id -> swapSlides() + StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id -> yieldPromoSlides() else -> persistentListOf() } @@ -34,4 +36,23 @@ internal object StoriesSlideConfigs { com.tangem.core.res.R.string.swap_story_forth_subtitle_v2, ), ) + + private fun yieldPromoSlides(): ImmutableList = persistentListOf( + SlideConfig( + CoreResR.string.yield_apy_boost_story_first_title, + CoreResR.string.yield_apy_boost_story_first_subtitle, + ), + SlideConfig( + CoreResR.string.yield_apy_boost_story_second_title, + CoreResR.string.yield_apy_boost_story_second_subtitle, + ), + SlideConfig( + CoreResR.string.yield_apy_boost_story_third_title, + CoreResR.string.yield_apy_boost_story_third_subtitle, + ), + SlideConfig( + CoreResR.string.yield_apy_boost_story_fourth_title, + CoreResR.string.yield_apy_boost_story_fourth_subtitle, + ), + ) } \ No newline at end of file diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt index 14482bc8fa..fb6fffcc22 100644 --- a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/model/StoriesModel.kt @@ -40,7 +40,7 @@ internal class StoriesModel @Inject constructor( private fun openScreen(hideStories: Boolean = true) { modelScope.launch { - if (hideStories) { + if (hideStories && params.shouldMarkAsSeenOnClose) { shouldShowStoriesUseCase.neverToShow(params.storyId) } router.pop() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 9d839be728..8f98d6d139 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -33,8 +33,10 @@ import com.tangem.domain.settings.NeverToSuggestRateAppUseCase import com.tangem.domain.settings.RemindToRateAppLaterUseCase import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher +import com.tangem.domain.stories.models.StoryContentIds import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.wallets.usecase.* +import com.tangem.domain.yield.supply.usecase.YieldSupplySetShouldShowMainPromoUseCase import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController @@ -87,6 +89,10 @@ internal interface WalletWarningsClickIntents { fun onDismissAssetsDiscoveryNotification(userWalletId: UserWalletId) fun onAssetsDiscoveryManageClick(userWalletId: UserWalletId) + + fun onYieldBoostBannerClick(userWalletId: UserWalletId) + + fun onDismissYieldBoostBanner(userWalletId: UserWalletId) } @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @@ -118,6 +124,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val reviewManager: ReviewManager, private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase, private val acknowledgeAssetsDiscoveryCompletionUseCase: AcknowledgeAssetsDiscoveryCompletionUseCase, + private val yieldSupplySetShouldShowMainPromoUseCase: YieldSupplySetShouldShowMainPromoUseCase, ) : BaseWalletClickIntents(), WalletWarningsClickIntents { override fun onAddBackupCardClick() { @@ -408,4 +415,21 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( AccountId.forMainCryptoPortfolio(userWalletId), ) } + + override fun onYieldBoostBannerClick(userWalletId: UserWalletId) { + appRouter.push( + Stories( + storyId = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id, + nextScreen = null, + screenSource = "YieldMainBanner", + shouldMarkAsSeenOnClose = false, + ), + ) + } + + override fun onDismissYieldBoostBanner(userWalletId: UserWalletId) { + modelScope.launch { + yieldSupplySetShouldShowMainPromoUseCase(shouldShow = false) + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt index ccd6ee0c3d..e72fb5180b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt @@ -98,6 +98,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor( is WalletNotification.Warning.TangemPayRefreshNeeded -> null is WalletNotification.Warning.TangemPayUnreachable -> null is WalletNotification.UpgradeHotWalletPromo -> null + is WalletNotification.YieldBoostPromo -> null is WalletNotification.AssetsDiscoveryCompleted -> null is WalletNotification.CreateTangemPayAccount -> TangemPayAnalyticsEvents.PermanentBannerShowed() } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index ee7cd5188e..ea093c657c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -5,6 +5,7 @@ package com.tangem.feature.wallet.presentation.wallet.domain import com.tangem.common.ui.notifications.NotificationId import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.models.AccountStatusList @@ -28,7 +29,10 @@ import com.tangem.domain.settings.IsReadyToShowRateAppUseCase import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase +import com.tangem.domain.yield.supply.promo.usecase.ShouldShowYieldBoostMainBannerUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.account.AccountDependencies @@ -60,6 +64,10 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( private val checkHotWalletUpgradeBannerUseCase: CheckHotWalletUpgradeBannerUseCase, private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase, + private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase, + private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, + private val designFeatureToggles: DesignFeatureToggles, ) { @Suppress("UNCHECKED_CAST", "MagicNumber", "LongMethod", "CastNullableToNonNullableType") @@ -87,6 +95,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( getUpgradeBannerClosureTimestampUseCase(userWallet.walletId) .distinctUntilChanged(), assetsDiscoveryProgressFlow, + yieldSupplyGetShouldShowMainPromoUseCase().distinctUntilChanged(), ) { array -> array } .map { array -> val accountStatusList = array[0] as AccountStatusList @@ -97,6 +106,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( val shouldShowUpgradeBanner = array[5] as Boolean val closureTimestamp = array[6] as? Long val assetsDiscoveryProgress = array[7] as AssetsDiscoveryProgress + val shouldShowYieldBoostPromoLocal = array[8] as Boolean val flattenCurrencies = accountStatusList.flattenCurrencies() val paymentAccountStatus = accountStatusList.accountStatuses @@ -165,10 +175,34 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( walletClickIntents = clickIntents, ) } + + addYieldBoostBannerNotification( + userWallet = userWallet, + shouldShowLocal = shouldShowYieldBoostPromoLocal, + clickIntents = clickIntents, + ) }.toImmutableList() } } + private suspend fun MutableList.addYieldBoostBannerNotification( + userWallet: UserWallet, + shouldShowLocal: Boolean, + clickIntents: WalletClickIntents, + ) { + if (!shouldShowLocal) return + if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return + if (designFeatureToggles.isRedesignEnabled) return + val shouldShow = shouldShowYieldBoostMainBannerUseCase(userWallet.walletId).getOrNull() == true + if (!shouldShow) return + add( + WalletNotification.YieldBoostPromo( + onClick = { clickIntents.onYieldBoostBannerClick(userWallet.walletId) }, + onCloseClick = { clickIntents.onDismissYieldBoostBanner(userWallet.walletId) }, + ), + ) + } + private fun MutableList.addTangemPayWarnings( status: AccountStatus.Payment, userWallet: UserWallet, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index 131397808d..2b25705935 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.components.notifications.NotificationConfig.IconTint import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType import com.tangem.feature.wallet.impl.R @@ -319,6 +320,27 @@ sealed class WalletNotification(val config: NotificationConfig) { ), ) + data class YieldBoostPromo( + val onClick: () -> Unit, + val onCloseClick: () -> Unit, + ) : WalletNotification( + config = NotificationConfig( + title = com.tangem.core.ui.extensions.combinedReference( + resourceReference(com.tangem.core.res.R.string.yield_apy_boost_banner_title), + stringReference(" · "), + resourceReference(com.tangem.core.res.R.string.yield_apy_boost_banner_title_apy_multiplied), + ), + subtitle = resourceReference(com.tangem.core.res.R.string.yield_apy_boost_banner_subtitle), + iconResId = com.tangem.core.ui.R.drawable.ic_analytics_up_24, + iconTint = IconTint.Accent, + onCloseClick = onCloseClick, + buttonsState = ButtonsState.PrimaryButtonConfig( + text = resourceReference(com.tangem.core.res.R.string.yield_apy_boost_banner_button_title), + onClick = onClick, + ), + ), + ) + data class AssetsDiscoveryCompleted( val onCloseClick: () -> Unit, val onManageTokensClick: () -> Unit, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt index 83b44bf068..935e2d3cb7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt @@ -1,6 +1,8 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.stories.GetStoryContentUseCase +import com.tangem.domain.stories.models.StoryContentIds import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsAnalyticsSender import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletWarningsSingleEventSender @@ -15,7 +17,9 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +@Suppress("LongParameterList") @Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]") internal class MultiWalletWarningsSubscriber @AssistedInject constructor( @Assisted private val userWallet: UserWallet, @@ -24,6 +28,7 @@ internal class MultiWalletWarningsSubscriber @AssistedInject constructor( private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, private val walletWarningsAnalyticsSender: WalletWarningsAnalyticsSender, private val walletWarningsSingleEventSender: WalletWarningsSingleEventSender, + private val getStoryContentUseCase: GetStoryContentUseCase, ) : WalletSubscriber() { override fun create(coroutineScope: CoroutineScope): Flow> { @@ -31,6 +36,15 @@ internal class MultiWalletWarningsSubscriber @AssistedInject constructor( .conflate() .distinctUntilChanged() .onEach { warnings -> + if (warnings.any { it is WalletNotification.YieldBoostPromo }) { + coroutineScope.launch { + getStoryContentUseCase.invokeSync( + id = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id, + refresh = true, + ) + } + } + val displayedState = stateController.getWalletState(userWallet.walletId) // Wait until the wallet appears in the list diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index a3ef3b68c4..4459d2403c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -2,11 +2,18 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.common import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import com.tangem.common.ui.notifications.CreatePaymentAccountNotification import com.tangem.core.ui.components.notifications.NoteMigrationNotification import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.annotatedReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.ForceDarkTheme import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.wallet.impl.R @@ -48,6 +55,14 @@ internal fun LazyListScope.notifications(configs: ImmutableList { + Notification( + config = item.config.copy(title = annotatedReference(yieldBoostPromoTitle())), + modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), + iconTint = TangemTheme.colors.icon.accent, + subtitleColor = TangemTheme.colors.text.secondary, + ) + } is WalletNotification.CreateTangemPayAccount -> { CreatePaymentAccountNotification( modifier = modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), @@ -76,4 +91,18 @@ internal fun LazyListScope.notifications(configs: ImmutableList diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt index 030660a4a2..bdd632163d 100644 --- a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt +++ b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/entry/YieldSupplyEntryRoute.kt @@ -15,6 +15,7 @@ sealed class YieldSupplyEntryRoute : Route { data class Promo( val cryptoCurrency: CryptoCurrency, val apy: String, + val isPromoEnabled: Boolean = false, ) : YieldSupplyEntryRoute() /** Route to yield supply active screen */ diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index 4bf8eda135..39c25e1264 100644 --- a/features/yield-supply/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -58,6 +58,8 @@ dependencies { implementation(projects.domain.transaction) implementation(projects.domain.yieldSupply.models) implementation(projects.domain.yieldSupply) + implementation(projects.domain.stories.models) + implementation(projects.domain.stories) implementation(projects.domain.feedback.models) implementation(projects.domain.feedback) implementation(projects.domain.balanceHiding.models) @@ -76,6 +78,7 @@ dependencies { implementation(deps.decompose) implementation(deps.decompose.ext.compose) implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.datetime) /** DI */ implementation(deps.hilt.android) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt new file mode 100644 index 0000000000..f277bfeff8 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt @@ -0,0 +1,15 @@ +package com.tangem.features.yield.supply.impl + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles +import javax.inject.Inject + +internal class DefaultYieldSupplyFeatureToggles @Inject constructor( + featureTogglesManager: FeatureTogglesManager, +) : YieldSupplyFeatureToggles { + + override val isYieldPromoEnabled: Boolean = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15154_YIELD_PROMO_ENABLED, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/YieldBoostStoryPreloader.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/YieldBoostStoryPreloader.kt new file mode 100644 index 0000000000..e171b86c85 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/YieldBoostStoryPreloader.kt @@ -0,0 +1,28 @@ +package com.tangem.features.yield.supply.impl + +import com.tangem.core.ui.coil.ImagePreloader +import com.tangem.domain.stories.GetStoryContentUseCase +import com.tangem.domain.stories.models.StoryContentIds +import com.tangem.utils.coroutines.runSuspendCatching +import javax.inject.Inject + +/** + * Warms the in-memory `StoriesStore` cache (and Coil image cache) for the yield-boost story. + * + * Called proactively from yield-supply models so that when the user taps "Learn more" / + * the active-boost row, [com.tangem.feature.stories.impl.model.StoriesModel] hits cache + * instead of waiting for the 1-second network fetch. + */ +internal class YieldBoostStoryPreloader @Inject constructor( + private val getStoryContentUseCase: GetStoryContentUseCase, + private val imagePreloader: ImagePreloader, +) { + + suspend fun preload() { + runSuspendCatching { + getStoryContentUseCase + .invokeSync(id = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id, refresh = true) + .onRight { story -> story?.getImageUrls()?.forEach(imagePreloader::preload) } + } + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/entity/YieldSupplyActiveContentUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/entity/YieldSupplyActiveContentUM.kt index 1cdb3d72d8..9f9ee55722 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/entity/YieldSupplyActiveContentUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/entity/YieldSupplyActiveContentUM.kt @@ -17,4 +17,6 @@ internal data class YieldSupplyActiveContentUM( val minFeeDescription: TextReference?, val apy: TextReference? = null, val isHighFee: Boolean = false, + val boostText: TextReference? = null, + val onBoostClick: () -> Unit = {}, ) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt index 4da4c1922b..9afb505aef 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt @@ -11,7 +11,10 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList @@ -25,15 +28,23 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.common.routing.AppRoute +import com.tangem.domain.stories.models.StoryContentIds +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.domain.yield.supply.promo.usecase.GetYieldBoostStatusUseCase import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R +import com.tangem.core.res.R as CoreResR +import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM import com.tangem.features.yield.supply.impl.active.model.transformers.YieldSupplyActiveFeeContentTransformer import com.tangem.features.yield.supply.impl.active.model.transformers.YieldSupplyActiveMinAmountTransformer import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyApproveComponent import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent +import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -41,7 +52,10 @@ import com.tangem.utils.transformer.update import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.datetime.Clock import javax.inject.Inject +import kotlin.math.max +import kotlin.time.Duration.Companion.milliseconds @Suppress("LongParameterList", "LargeClass") @ModelScoped @@ -60,6 +74,10 @@ internal class YieldSupplyActiveModel @Inject constructor( private val urlOpener: UrlOpener, private val appRouter: AppRouter, private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, + private val getYieldBoostStatusUseCase: GetYieldBoostStatusUseCase, + private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, + private val designFeatureToggles: DesignFeatureToggles, + private val boostStoryPreloader: YieldBoostStoryPreloader, ) : Model(), YieldSupplyStopEarningComponent.ModelCallback, YieldSupplyApproveComponent.ModelCallback { @@ -112,6 +130,8 @@ internal class YieldSupplyActiveModel @Inject constructor( ), ) subscribeOnCurrencyStatusUpdates() + loadBoostBlock() + modelScope.launch(dispatchers.io) { boostStoryPreloader.preload() } modelScope.launch(dispatchers.default) { appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } @@ -219,6 +239,72 @@ internal class YieldSupplyActiveModel @Inject constructor( } } + private fun loadBoostBlock() { + if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return + if (designFeatureToggles.isRedesignEnabled) return + modelScope.launch(dispatchers.io) { + val status = getYieldBoostStatusUseCase(userWalletId).getOrNull() ?: return@launch + val token = cryptoCurrency as? CryptoCurrency.Token ?: return@launch + when { + status is YieldBoostStatus.Active && status.matches(token) -> { + uiState.update { + it.copy(boostText = buildActiveBoostText(status), onBoostClick = ::onBoostClick) + } + } + status is YieldBoostStatus.Completed && status.matches(token) -> { + uiState.update { + it.copy( + boostText = resourceReference(CoreResR.string.yield_promo_completed), + onBoostClick = ::onBoostClick, + ) + } + } + } + } + } + + private fun onBoostClick() { + appRouter.push( + AppRoute.Stories( + storyId = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id, + nextScreen = null, + screenSource = "YieldActive", + shouldMarkAsSeenOnClose = false, + ), + ) + } + + private fun buildActiveBoostText(status: YieldBoostStatus.Active): TextReference { + val daysLeft = computeDaysLeft(status.qualificationEndDate.toEpochMilliseconds()) + return combinedReference( + pluralReference( + id = CoreResR.plurals.common_days, + count = daysLeft, + formatArgs = wrappedList(daysLeft), + ), + stringReference(" "), + resourceReference(CoreResR.string.yield_promo_left_title), + ) + } + + private fun computeDaysLeft(qualificationEndEpochMillis: Long): Int { + val nowMillis = Clock.System.now().toEpochMilliseconds() + val deltaMillis = max(qualificationEndEpochMillis - nowMillis, 0L) + return deltaMillis.milliseconds.inWholeDays.toInt() + } + + private fun YieldBoostStatus.Active.matches(token: CryptoCurrency.Token): Boolean = + matchesToken(contractAddress = contractAddress, networkId = networkId, token = token) + + private fun YieldBoostStatus.Completed.matches(token: CryptoCurrency.Token): Boolean = + matchesToken(contractAddress = contractAddress, networkId = networkId, token = token) + + private fun matchesToken(contractAddress: String, networkId: String, token: CryptoCurrency.Token): Boolean { + val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId) + return contractAddress.equals(token.contractAddress, ignoreCase = shouldIgnoreCase) && + networkId == token.network.rawId + } + private fun loadApy() { val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return modelScope.launch(dispatchers.default) { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/ui/YieldSupplyActiveContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/ui/YieldSupplyActiveContent.kt index e01e0e702c..6174cc96e2 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/ui/YieldSupplyActiveContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/ui/YieldSupplyActiveContent.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -43,6 +44,7 @@ import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM import kotlinx.collections.immutable.persistentListOf +@Suppress("LongMethod") @Composable internal fun YieldSupplyActiveContent( state: YieldSupplyActiveContentUM, @@ -61,15 +63,29 @@ internal fun YieldSupplyActiveContent( ), ) { Column( - verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .clip(RoundedCornerShape(16.dp)) .background(TangemTheme.colors.background.action) - .fillMaxWidth() - .padding(12.dp), + .fillMaxWidth(), ) { - CurrentApy(state.apy) - chartComponent.Content(Modifier) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(12.dp), + ) { + CurrentApy(state.apy) + chartComponent.Content(Modifier) + } + AnimatedVisibility(state.boostText != null) { + Column { + HorizontalDivider( + thickness = TangemTheme.dimens.size0_5, + color = TangemTheme.colors.stroke.primary, + ) + state.boostText?.let { boostText -> + BoostRow(text = boostText, onClick = state.onBoostClick) + } + } + } } AnimatedVisibility(state.notifications.isNotEmpty()) { @@ -359,6 +375,37 @@ private fun HighComissionInfoRow(title: TextReference, info: TextReference?, isH } } +@Composable +private fun BoostRow(text: TextReference, onClick: () -> Unit, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_gift_promo_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + text = text.resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .weight(1f) + .padding(start = 12.dp), + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier.size(20.dp), + ) + } +} + // region Preview @Composable @Preview(showBackground = true, widthDp = 360) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt new file mode 100644 index 0000000000..0905d00fe8 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt @@ -0,0 +1,21 @@ +package com.tangem.features.yield.supply.impl.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles +import com.tangem.features.yield.supply.impl.DefaultYieldSupplyFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object YieldSupplyFeatureModule { + + @Provides + @Singleton + fun provideYieldSupplyFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldSupplyFeatureToggles { + return DefaultYieldSupplyFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt index 0d5f50a0c7..66b0e77e67 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/DefaultYieldSupplyEntryComponent.kt @@ -86,6 +86,7 @@ internal class DefaultYieldSupplyEntryComponent @AssistedInject constructor( userWalletId = params.userWalletId, currency = configuration.cryptoCurrency, apy = configuration.apy, + isPromoEnabled = configuration.isPromoEnabled, ), ) is YieldSupplyEntryRoute.Active -> yieldSupplyActiveComponentFactory.create( diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt index 649028c56e..f76c3881c8 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt @@ -1,17 +1,21 @@ package com.tangem.features.yield.supply.impl.entry.model +import arrow.core.getOrElse import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.tokens.model.details.NavigationAction +import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.launch @@ -20,12 +24,16 @@ import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @ModelScoped +@Suppress("LongParameterList") internal class YieldSupplyEntryModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase, + private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, + private val designFeatureToggles: DesignFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -90,7 +98,14 @@ internal class YieldSupplyEntryModel @Inject constructor( return if (isActiveYield) { YieldSupplyEntryRoute.Active(cryptoCurrency = token) } else { - YieldSupplyEntryRoute.Promo(cryptoCurrency = token, apy = params.apy) + val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled && + !designFeatureToggles.isRedesignEnabled && + isYieldBoostPromoEnabledForTokenUseCase(userWalletId, token).getOrElse { false } + YieldSupplyEntryRoute.Promo( + cryptoCurrency = token, + apy = params.apy, + isPromoEnabled = isPromoEnabled, + ) } } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt index a3ec2d930e..b116771557 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/entity/YieldSupplyUM.kt @@ -13,6 +13,8 @@ internal sealed class YieldSupplyUM { val apyText: TextReference, val title: TextReference, val onClick: () -> Unit, + val onLearnMoreClick: () -> Unit, + val isBoostAvailable: Boolean = false, ) : YieldSupplyUM() data object Loading : YieldSupplyUM() diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyClickIntents.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyClickIntents.kt index a13e1b98fe..5fdfb3a59a 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyClickIntents.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyClickIntents.kt @@ -3,4 +3,5 @@ package com.tangem.features.yield.supply.impl.main.model interface YieldSupplyClickIntents { fun onStartEarningClick() fun onActiveClick() + fun onLearnMoreClick() } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 03dbe46227..3a6df47bf7 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -8,6 +8,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.combinedReference import com.tangem.core.ui.extensions.resourceReference @@ -24,12 +25,17 @@ import com.tangem.domain.models.currency.shouldShowNotSuppliedNotification import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.yield.supply.YieldSupplyStatus import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.stories.models.StoryContentIds import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus +import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase +import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyComponent +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader import com.tangem.common.ui.earn.EarnBlockUM import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.features.yield.supply.impl.main.model.converter.YieldSupplyToEarnBlockConverter @@ -61,6 +67,11 @@ internal class YieldSupplyModel @Inject constructor( private val yieldSupplyEnterStatusFlowUseCase: YieldSupplyEnterStatusFlowUseCase, private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase, private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, + private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase, + private val getBoostedApyUseCase: GetBoostedApyUseCase, + private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, + private val designFeatureToggles: DesignFeatureToggles, + private val boostStoryPreloader: YieldBoostStoryPreloader, ) : Model(), YieldSupplyClickIntents { private val earnBlockConverter = YieldSupplyToEarnBlockConverter() @@ -82,6 +93,7 @@ internal class YieldSupplyModel @Inject constructor( init { checkIfYieldSupplyIsAvailable() + modelScope.launch(dispatchers.io) { boostStoryPreloader.preload() } } private fun checkIfYieldSupplyIsAvailable() { @@ -150,10 +162,17 @@ internal class YieldSupplyModel @Inject constructor( val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) .onRight { tokenStatus -> + val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled && + !designFeatureToggles.isRedesignEnabled && + isYieldBoostPromoEnabledForTokenUseCase(params.userWalletId, cryptoCurrencyToken) + .getOrElse { false } + val boostedApy = if (isPromoEnabled) getBoostedApyUseCase(tokenStatus.apy) else null uiStateLegacy.update( YieldSupplyTokenStatusSuccessTransformer( tokenStatus = tokenStatus, onStartEarningClick = ::onStartEarningClick, + onLearnMoreClick = ::onLearnMoreClick, + boostedApy = boostedApy, ), ) }.onLeft { error -> @@ -170,19 +189,33 @@ internal class YieldSupplyModel @Inject constructor( navigateToYieldSupplyEntry() } + override fun onLearnMoreClick() { + appRouter.push( + AppRoute.Stories( + storyId = StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id, + nextScreen = buildYieldEntryRoute(), + screenSource = "TokenDetails", + shouldMarkAsSeenOnClose = false, + ), + ) + } + private fun navigateToYieldSupplyEntry() { - val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return + val route = buildYieldEntryRoute() ?: return + appRouter.push(route) + } + + private fun buildYieldEntryRoute(): AppRoute.YieldSupplyEntry? { + val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return null val apy = when (val yieldSupplyUM = uiStateLegacy.value) { is YieldSupplyUM.Available -> yieldSupplyUM.apy is YieldSupplyUM.Content -> yieldSupplyUM.apy else -> "" } - appRouter.push( - AppRoute.YieldSupplyEntry( - userWalletId = params.userWalletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - apy = apy, - ), + return AppRoute.YieldSupplyEntry( + userWalletId = params.userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + apy = apy, ) } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt index ef9f8b8a99..77dde2b775 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformer.kt @@ -1,5 +1,10 @@ package com.tangem.features.yield.supply.impl.main.model.transformers +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import com.tangem.core.ui.extensions.annotatedReference import com.tangem.core.ui.extensions.combinedReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -7,27 +12,45 @@ import com.tangem.domain.yield.supply.models.YieldMarketToken import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.utils.transformer.Transformer +import java.math.BigDecimal internal class YieldSupplyTokenStatusSuccessTransformer( private val tokenStatus: YieldMarketToken, private val onStartEarningClick: () -> Unit, + private val onLearnMoreClick: () -> Unit, + private val boostedApy: BigDecimal? = null, ) : Transformer { override fun transform(prevState: YieldSupplyUM): YieldSupplyUM { if (!tokenStatus.isActive) return YieldSupplyUM.Unavailable + val boost = boostedApy return YieldSupplyUM.Available( - title = resourceReference( - R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, - ), + title = if (boost != null) { + resourceReference(R.string.yield_apy_boost_banner_title) + } else { + resourceReference(R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title) + }, onClick = onStartEarningClick, + onLearnMoreClick = onLearnMoreClick, + isBoostAvailable = boost != null, apy = tokenStatus.apy.toString(), - apyText = combinedReference( - resourceReference( - R.string.yield_module_token_details_earn_notification_apy, - ), - stringReference(" ${tokenStatus.apy}%"), - ), + apyText = if (boost != null) { + annotatedReference(buildBoostedApyText(baseApy = tokenStatus.apy, boostedApy = boost)) + } else { + combinedReference( + resourceReference(R.string.yield_module_token_details_earn_notification_apy), + stringReference(" ${tokenStatus.apy}%"), + ) + }, ) } + + private fun buildBoostedApyText(baseApy: BigDecimal, boostedApy: BigDecimal) = buildAnnotatedString { + append("APY ") + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + append("$baseApy%") + } + append(" x3 → $boostedApy%") + } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt index 7286fa3c1e..2cf8294962 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.SpacerW8 @@ -64,21 +65,91 @@ internal fun YieldSupplyBlockContentLegacy(yieldSupplyUM: YieldSupplyUM, modifie @Composable private fun SupplyAvailable(supplyUM: YieldSupplyUM.Available, modifier: Modifier = Modifier) { - SupplyInfo( - title = resourceReference(R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title), - subtitle = resourceReference(R.string.yield_module_token_details_earn_notification_description), - rewardsApy = supplyUM.apyText, - iconTint = TangemTheme.colors.icon.accent, - modifier = modifier, - button = { + if (supplyUM.isBoostAvailable) { + SupplyAvailableBoosted(supplyUM = supplyUM, modifier = modifier) + } else { + SupplyInfo( + title = resourceReference( + R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, + ), + subtitle = resourceReference(R.string.yield_module_token_details_earn_notification_description), + rewardsApy = supplyUM.apyText, + iconTint = TangemTheme.colors.icon.accent, + modifier = modifier, + button = { + SecondaryButton( + text = stringResourceSafe(R.string.common_learn_more), + onClick = supplyUM.onClick, + size = TangemButtonSize.WideAction, + modifier = Modifier.fillMaxWidth(), + ) + }, + ) + } +} + +@Composable +private fun SupplyAvailableBoosted(supplyUM: YieldSupplyUM.Available, modifier: Modifier = Modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.primary) + .padding(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_analytics_up_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier + .background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f), CircleShape) + .padding(6.dp) + .size(24.dp), + ) + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.weight(1f), + ) { + Text( + text = supplyUM.title.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = supplyUM.apyText.resolveAnnotatedReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.accent, + ) + Text( + text = stringResourceSafe(R.string.yield_apy_boost_banner_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { SecondaryButton( text = stringResourceSafe(R.string.common_learn_more), + onClick = supplyUM.onLearnMoreClick, + size = TangemButtonSize.WideAction, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResourceSafe(R.string.common_activate), onClick = supplyUM.onClick, size = TangemButtonSize.WideAction, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(1f), ) - }, - ) + } + } } @Suppress("LongMethod") @@ -345,6 +416,15 @@ private class PreviewProvider : PreviewParameterProvider { apy = "5.1", apyText = stringReference("5.1 % APY"), onClick = {}, + onLearnMoreClick = {}, + ), + YieldSupplyUM.Available( + title = TextReference.Res(R.string.yield_apy_boost_banner_title), + apy = "5.1", + apyText = stringReference("APY 5.1% x3 → 15.3%"), + onClick = {}, + onLearnMoreClick = {}, + isBoostAvailable = true, ), YieldSupplyUM.Content( title = stringReference("Aave l"), diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt index e10b365bf0..9c6e634efe 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/entity/YieldSupplyPromoUM.kt @@ -5,7 +5,11 @@ import com.tangem.core.ui.extensions.TextReference data class YieldSupplyPromoUM( val tosLink: String, val policyLink: String, + val boostTermsLink: String, val title: TextReference, val subtitle: TextReference, val tokenSymbol: String, + val isBoostAvailable: Boolean = false, + val baseApy: String? = null, + val boostedApy: String? = null, ) \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt index 438a5ceb15..7ed5f69c13 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.yield.supply.impl.promo.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.common.TangemSiteUrlBuilder import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -11,15 +12,19 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader import com.tangem.features.yield.supply.impl.promo.YieldSupplyPromoConfig import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.launch +import java.math.BigDecimal import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class YieldSupplyPromoModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, @@ -27,22 +32,15 @@ internal class YieldSupplyPromoModel @Inject constructor( private val analytics: AnalyticsEventHandler, private val urlOpener: UrlOpener, private val appRouter: AppRouter, + private val getBoostedApyUseCase: GetBoostedApyUseCase, + private val boostStoryPreloader: YieldBoostStoryPreloader, ) : Model(), YieldSupplyPromoClickIntents { val params: YieldSupplyPromoComponent.Params = paramsContainer.require() - val uiState: YieldSupplyPromoUM = YieldSupplyPromoUM( - tosLink = AAVE_TOS_URL, - policyLink = AAVE_PRIVACY_URL, - tokenSymbol = params.currency.symbol, - title = resourceReference( - R.string.yield_module_promo_screen_title_v2, - wrappedList(params.apy), - ), - subtitle = resourceReference( - R.string.yield_module_promo_screen_variable_rate_info_v2, - ), - ) + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + val uiState: YieldSupplyPromoUM = buildUiState() init { analytics.send( @@ -51,10 +49,9 @@ internal class YieldSupplyPromoModel @Inject constructor( blockchain = params.currency.network.name, ), ) + modelScope.launch(dispatchers.io) { boostStoryPreloader.preload() } } - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - override fun onBackClick() { appRouter.pop() } @@ -78,6 +75,31 @@ internal class YieldSupplyPromoModel @Inject constructor( bottomSheetNavigation.activate(YieldSupplyPromoConfig.Action) } + private fun buildUiState(): YieldSupplyPromoUM { + val isBoost = params.isPromoEnabled + val baseApyText = if (isBoost) "${params.apy}%" else null + val boostedApyText = if (isBoost) { + val baseApy = params.apy.toBigDecimalOrNull() ?: BigDecimal.ZERO + "${getBoostedApyUseCase(baseApy)}%" + } else { + null + } + return YieldSupplyPromoUM( + tosLink = AAVE_TOS_URL, + policyLink = AAVE_PRIVACY_URL, + boostTermsLink = TangemSiteUrlBuilder.YIELD_MODE_TERMS_URL, + tokenSymbol = params.currency.symbol, + isBoostAvailable = isBoost, + baseApy = baseApyText, + boostedApy = boostedApyText, + title = resourceReference( + R.string.yield_module_promo_screen_title_v2, + wrappedList(params.apy), + ), + subtitle = resourceReference(R.string.yield_module_promo_screen_variable_rate_info_v2), + ) + } + private companion object { const val AAVE_TOS_URL = "https://aave.com/terms-of-service" const val AAVE_PRIVACY_URL = "https://aave.com/privacy-policy" diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt index 868ebb1473..28d299eb79 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -16,12 +17,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.* @@ -73,7 +79,7 @@ internal fun YieldSupplyPromoContent( } } -@Suppress("MagicNumber") +@Suppress("MagicNumber", "LongMethod") @Composable private fun ColumnScope.Content(yieldSupplyPromoUM: YieldSupplyPromoUM, clickIntents: YieldSupplyPromoClickIntents) { Box(modifier = Modifier.weight(1f)) { @@ -98,12 +104,22 @@ private fun ColumnScope.Content(yieldSupplyPromoUM: YieldSupplyPromoUM, clickInt .size(32.dp), ) SpacerH(20.dp) - Text( - text = yieldSupplyPromoUM.title.resolveReference(), - style = TangemTheme.typography.h2, - textAlign = TextAlign.Center, - color = TangemTheme.colors.text.primary1, - ) + if (yieldSupplyPromoUM.isBoostAvailable && + yieldSupplyPromoUM.baseApy != null && + yieldSupplyPromoUM.boostedApy != null + ) { + BoostPromoTitle( + baseApy = yieldSupplyPromoUM.baseApy, + boostedApy = yieldSupplyPromoUM.boostedApy, + ) + } else { + Text( + text = yieldSupplyPromoUM.title.resolveReference(), + style = TangemTheme.typography.h2, + textAlign = TextAlign.Center, + color = TangemTheme.colors.text.primary1, + ) + } SpacerH8() Label( state = LabelUM( @@ -117,6 +133,17 @@ private fun ColumnScope.Content(yieldSupplyPromoUM: YieldSupplyPromoUM, clickInt SpacerH32() PromoItems(yieldSupplyPromoUM.tokenSymbol) } + if (yieldSupplyPromoUM.isBoostAvailable && + yieldSupplyPromoUM.baseApy != null && + yieldSupplyPromoUM.boostedApy != null + ) { + SpacerH(20.dp) + PromoBoostCard( + baseApy = yieldSupplyPromoUM.baseApy, + boostedApy = yieldSupplyPromoUM.boostedApy, + onLearnMoreClick = { clickIntents.onUrlClick(yieldSupplyPromoUM.boostTermsLink) }, + ) + } SpacerH32() } Fade( @@ -176,6 +203,102 @@ private fun PromoItems(tokenSymbol: String) { ) } +@Suppress("MagicNumber") +@Composable +private fun BoostPromoTitle(baseApy: String, boostedApy: String) { + val accent = TangemTheme.colors.text.accent + val primary = TangemTheme.colors.text.primary1 + // Pass `%1$s` back as the argument so the placeholder survives formatting (`%%` → `%`). + val raw = stringResourceSafe(R.string.yield_module_promo_screen_title_v2, "%1\$s") + val (head, rest) = raw.split("%1\$s", limit = 2) + // The template leaves a stray `%` right after the value (after a space in RU/UK), but the APY + // strings already carry their own `%` — drop that duplicate. + val tail = rest.trimStart().removePrefix("%") + val annotated = buildAnnotatedString { + append(head) + withStyle(SpanStyle(color = accent, textDecoration = TextDecoration.LineThrough)) { + append(baseApy) + } + // Arrow glyph sits lower than digits in most fonts; lift it onto the cap-height baseline. + withStyle(SpanStyle(color = accent, baselineShift = BaselineShift(0.1f))) { + append(" → ") + } + withStyle(SpanStyle(color = accent)) { + append(boostedApy) + } + append(tail) + } + Text( + text = annotated, + style = TangemTheme.typography.h2, + textAlign = TextAlign.Center, + color = primary, + ) +} + +@Composable +private fun PromoBoostCard(baseApy: String, boostedApy: String, onLearnMoreClick: () -> Unit) { + val accent = TangemTheme.colors.text.accent + val primary = TangemTheme.colors.text.primary1 + val tertiary = TangemTheme.colors.text.tertiary + val titleAnnotated = buildAnnotatedString { + withStyle(SpanStyle(color = primary)) { + append(stringResourceSafe(R.string.common_yield_mode)) + append(" · ") + } + withStyle(SpanStyle(color = accent)) { + append("APY ") + } + withStyle(SpanStyle(color = accent, textDecoration = TextDecoration.LineThrough)) { + append(baseApy) + } + withStyle(SpanStyle(color = accent)) { + append(" x3 → ") + append(boostedApy) + } + } + val learnMoreLabel = stringResourceSafe(R.string.common_learn_more).lowercase() + val eligibilityText = stringResourceSafe(R.string.yield_apy_boost_promo_eligibility_text) + val subtitleAnnotated = buildAnnotatedString { + append(eligibilityText) + append(" ") + withLink( + link = LinkAnnotation.Clickable( + tag = "YIELD_BOOST_LEARN_MORE", + linkInteractionListener = { onLearnMoreClick() }, + ), + block = { + appendColored(text = learnMoreLabel, color = accent) + }, + ) + } + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.primary) + .padding(12.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_gift_promo_24), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + Column( + modifier = Modifier.padding(start = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(text = titleAnnotated, style = TangemTheme.typography.subtitle2) + Text( + text = subtitleAnnotated, + style = TangemTheme.typography.caption2, + color = tertiary, + ) + } + } +} + @Composable private fun PromoItem(@DrawableRes icon: Int, title: TextReference, subtitle: TextReference) { Row( @@ -262,9 +385,11 @@ private fun YieldSupplyPromoContent_Preview() { yieldSupplyPromoUM = YieldSupplyPromoUM( tosLink = "https://tangem.com/terms-of-service/", policyLink = "https://tangem.com/privacy-policy/", + boostTermsLink = "https://tangem.com/docs/en/yield-mode-terms.pdf", title = resourceReference(R.string.yield_module_promo_screen_title), tokenSymbol = "USDT", subtitle = resourceReference(R.string.yield_module_promo_screen_variable_rate_info, wrappedList("5.3")), + isBoostAvailable = false, ), clickIntents = object : YieldSupplyPromoClickIntents { override fun onBackClick() {} diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt index a1437580b7..b1279d5ce8 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt @@ -155,6 +155,7 @@ internal class YieldSupplyToEarnBlockConverterTest { apyText = stringReference("5.1 % APY"), title = stringReference("Yield Mode"), onClick = { clicked = true }, + onLearnMoreClick = {}, ) val result = converter.convert(available)