From a2c8e7e95ae0e8acb795801d3c3edf9e4d23d392 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Jul 2026 12:07:08 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 + features/promo-banners/api/build.gradle.kts | 1 + .../api/swapcashback/CampaignsComponent.kt | 14 ++ .../api/toggles/PromoBannersFeatureToggles.kt | 6 + features/promo-banners/impl/build.gradle.kts | 7 + .../ActivateCampaignBottomSheetComponent.kt | 64 +++++++++ .../CampaignEnrolledBottomSheetComponent.kt | 50 +++++++ .../component/DefaultCampaignsComponent.kt | 72 ++++++++++ .../NotActiveCampaignBottomSheetComponent.kt | 44 ++++++ .../converters/CampaignIdConverter.kt | 22 +++ .../impl/campaigns/di/CampaignsModule.kt | 39 +++++ .../campaigns/entity/ActivateCampaignUM.kt | 20 +++ .../impl/campaigns/entity/CampaignType.kt | 12 ++ .../impl/campaigns/entity/CampaignTypeExt.kt | 7 + .../entity/CampaignsBottomSheetConfig.kt | 20 +++ .../campaigns/model/ActivateCampaignsModel.kt | 110 ++++++++++++++ .../impl/campaigns/model/CampaignsModel.kt | 56 ++++++++ .../ui/ActivateCampaignBottomSheet.kt | 50 +++++++ .../campaigns/ui/ActivateCampaignContent.kt | 135 ++++++++++++++++++ .../impl/di/PromoBannersFeatureModule.kt | 6 + .../DefaultPromoBannersFeatureToggles.kt | 16 +++ 21 files changed, 755 insertions(+) create mode 100644 features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/swapcashback/CampaignsComponent.kt create mode 100644 features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/toggles/PromoBannersFeatureToggles.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignContent.kt create mode 100644 features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultPromoBannersFeatureToggles.kt 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 581ed0dd70..d538540a1f 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 @@ -162,5 +162,9 @@ { "name": "TWI_1638_VA_MVP0_ENABLED", "version": "6.1" + }, + { + "name": "TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED", + "version": "undefined" } ] diff --git a/features/promo-banners/api/build.gradle.kts b/features/promo-banners/api/build.gradle.kts index d3084eb673..9bba5dce8f 100644 --- a/features/promo-banners/api/build.gradle.kts +++ b/features/promo-banners/api/build.gradle.kts @@ -13,4 +13,5 @@ dependencies { api(deps.compose.ui) api(projects.core.decompose) + api(projects.core.ui) } \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/swapcashback/CampaignsComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/swapcashback/CampaignsComponent.kt new file mode 100644 index 0000000000..bfc1ca6169 --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/swapcashback/CampaignsComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.promobanners.api.swapcashback + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +/** + + * once at app startup (hence [Unit] params) and reacts to campaign requests coming through the + * promo-campaigns bus, not to navigation. + */ +interface CampaignsComponent : ComposableContentComponent { + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/toggles/PromoBannersFeatureToggles.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/toggles/PromoBannersFeatureToggles.kt new file mode 100644 index 0000000000..ca4c07bc8a --- /dev/null +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/toggles/PromoBannersFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.promobanners.api.toggles + +interface PromoBannersFeatureToggles { + + val isCampaignsToggleEnabled: Boolean +} \ No newline at end of file diff --git a/features/promo-banners/impl/build.gradle.kts b/features/promo-banners/impl/build.gradle.kts index b254857e0d..1f23256963 100644 --- a/features/promo-banners/impl/build.gradle.kts +++ b/features/promo-banners/impl/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) alias(deps.plugins.kotlin.kapt) alias(deps.plugins.hilt.android) id("configuration") @@ -13,12 +14,17 @@ android { dependencies { /** Project - API */ api(projects.features.promoBanners.api) + implementation(projects.features.commonFeatures.api) + implementation(projects.common.routing) + implementation(projects.common.ui) /** Domain */ api(projects.domain.common) implementation(projects.domain.models) + implementation(projects.domain.appCurrency) /** Core */ + api(projects.core.configToggles) api(projects.core.analytics) api(projects.core.datasource) api(projects.core.decompose) @@ -37,6 +43,7 @@ dependencies { implementation(deps.androidx.appCompat) implementation(deps.androidx.core.ktx) implementation(deps.decompose) + implementation(deps.decompose.ext.compose) implementation(deps.kotlin.coroutines) implementation(deps.kotlin.immutable.collections) diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt new file mode 100644 index 0000000000..97ffbc4835 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/ActivateCampaignBottomSheetComponent.kt @@ -0,0 +1,64 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel +import com.tangem.features.promobanners.impl.campaigns.ui.ActivateCampaignContent + +internal class ActivateCampaignBottomSheetComponent( + appComponentContext: AppComponentContext, + chooseTokenComponentFactory: ChooseTokenComponent.Factory, + params: ActivateCampaignsModel.Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: ActivateCampaignsModel = getOrCreateModel(params) + + private val chooseTokenComponent: ChooseTokenComponent = chooseTokenComponentFactory.create( + context = child(key = "swapCashbackChooseToken"), + params = ChooseTokenComponent.Params(bridge = model.bridge), + ) + + override fun dismiss() = model.onDismiss() + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + + ActivateCampaignContent( + state = state, + onSelectTokenClick = model::onSelectTokenClick, + onEnrollClick = model::onEnrollClick, + onLearnMoreClick = { /* [REDACTED_TODO_COMMENT] */ }, + onDismiss = ::dismiss, + ) + + if (state.isChoosingToken) { + ChooseTokenBottomSheet() + } + } + + @Composable + private fun ChooseTokenBottomSheet() { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = model::onChooseTokenDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = model::onChooseTokenDismiss, + ) { + chooseTokenComponent.Content(modifier = Modifier.fillMaxWidth()) + } + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt new file mode 100644 index 0000000000..8bdbfb6ac7 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/CampaignEnrolledBottomSheetComponent.kt @@ -0,0 +1,50 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.infoBlock +import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.onClick +import com.tangem.core.ui.components.bottomsheets.message.onDismiss +import com.tangem.core.ui.components.bottomsheets.message.primaryButton +import com.tangem.core.ui.components.bottomsheets.message.vector +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_success_24 +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.entity.campaignName + +internal class CampaignEnrolledBottomSheetComponent( + private val campaignType: CampaignType, + private val onDismissRequest: () -> Unit, +) : ComposableBottomSheetComponent { + + override fun dismiss() = onDismissRequest() + + @Composable + override fun BottomSheet() { + MessageBottomSheet( + state = messageBottomSheetUM { + onDismiss(onDismissRequest) + infoBlock { + vector(Icons.ic_success_24) { + type = MessageBottomSheetUM.Vector.Type.Accent + backgroundType = MessageBottomSheetUM.Vector.BackgroundType.SameAsTint + } + + title = stringReference("You're successfully enrolled in ${campaignType.campaignName()}") + body = stringReference("Your cashback will be applied to eligible swaps automatically.") + } + primaryButton { + text = resourceReference(R.string.common_close) + onClick { closeBs() } + } + }, + onDismissRequest = onDismissRequest, + ) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt new file mode 100644 index 0000000000..e815a741c6 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/DefaultCampaignsComponent.kt @@ -0,0 +1,72 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig +import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel +import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultCampaignsComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: Unit, + private val chooseTokenComponentFactory: ChooseTokenComponent.Factory, +) : CampaignsComponent, AppComponentContext by appComponentContext { + + private val model: CampaignsModel = getOrCreateModel() + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = CampaignsBottomSheetConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val bottomSheet by bottomSheetSlot.subscribeAsState() + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: CampaignsBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + val context = childByContext(componentContext) + return when (config) { + CampaignsBottomSheetConfig.NotActive -> NotActiveCampaignBottomSheetComponent( + onDismissRequest = model::onDismiss, + ) + is CampaignsBottomSheetConfig.Enrolled -> CampaignEnrolledBottomSheetComponent( + campaignType = config.campaignType, + onDismissRequest = model::onDismiss, + ) + is CampaignsBottomSheetConfig.Activate -> ActivateCampaignBottomSheetComponent( + appComponentContext = context, + chooseTokenComponentFactory = chooseTokenComponentFactory, + params = ActivateCampaignsModel.Params( + campaignType = config.campaignType, + onDismiss = model::onDismiss, + onActivated = model::onActivated, + ), + ) + } + } + + @AssistedFactory + interface Factory : CampaignsComponent.Factory { + override fun create(context: AppComponentContext, params: Unit): DefaultCampaignsComponent + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt new file mode 100644 index 0000000000..005d3c057e --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/component/NotActiveCampaignBottomSheetComponent.kt @@ -0,0 +1,44 @@ +package com.tangem.features.promobanners.impl.campaigns.component + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet +import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.icon +import com.tangem.core.ui.components.bottomsheets.message.infoBlock +import com.tangem.core.ui.components.bottomsheets.message.messageBottomSheetUM +import com.tangem.core.ui.components.bottomsheets.message.onClick +import com.tangem.core.ui.components.bottomsheets.message.onDismiss +import com.tangem.core.ui.components.bottomsheets.message.primaryButton +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.promobanners.impl.R + +internal class NotActiveCampaignBottomSheetComponent( + private val onDismissRequest: () -> Unit, +) : ComposableBottomSheetComponent { + + override fun dismiss() = onDismissRequest() + + @Composable + override fun BottomSheet() { + MessageBottomSheet( + state = messageBottomSheetUM { + onDismiss(onDismissRequest) + infoBlock { + icon(R.drawable.ic_alert_circle_24) { + type = MessageBottomSheetUM.Icon.Type.Warning + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint + } + title = stringReference("Campaign not active") // TODO localization + body = stringReference("This campaign no longer exists or has expired.") // TODO localization + } + primaryButton { + text = resourceReference(R.string.common_close) + onClick { closeBs() } + } + }, + onDismissRequest = onDismissRequest, + ) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt new file mode 100644 index 0000000000..bf2a879216 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/converters/CampaignIdConverter.kt @@ -0,0 +1,22 @@ +package com.tangem.features.promobanners.impl.campaigns.converters + +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.utils.converter.Converter +import javax.inject.Inject + +internal class CampaignIdConverter @Inject constructor() : + Converter { + + override fun convert(value: String): CampaignType? { + return when (value) { + CAMPAIGN_ID_REACTIVATION -> CampaignType.ReactivationCashback(campaignId = value) + CAMPAIGN_ID_WHALE -> CampaignType.WhaleSwapCashback(campaignId = value) + else -> null + } + } + + private companion object { + const val CAMPAIGN_ID_WHALE = "1" + const val CAMPAIGN_ID_REACTIVATION = "2" + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt new file mode 100644 index 0000000000..bc1085694b --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/di/CampaignsModule.kt @@ -0,0 +1,39 @@ +package com.tangem.features.promobanners.impl.campaigns.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.promobanners.api.swapcashback.CampaignsComponent +import com.tangem.features.promobanners.impl.campaigns.component.DefaultCampaignsComponent +import com.tangem.features.promobanners.impl.campaigns.model.ActivateCampaignsModel +import com.tangem.features.promobanners.impl.campaigns.model.CampaignsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface CampaignsModule { + + @Binds + @Singleton + fun bindCampaignsComponentFactory(factory: DefaultCampaignsComponent.Factory): CampaignsComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface CampaignsModelModule { + + @Binds + @IntoMap + @ClassKey(CampaignsModel::class) + fun bindCampaignModel(model: CampaignsModel): Model + + @Binds + @IntoMap + @ClassKey(ActivateCampaignsModel::class) + fun bindCampaignActivateModel(model: ActivateCampaignsModel): Model +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt new file mode 100644 index 0000000000..40e312763e --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/ActivateCampaignUM.kt @@ -0,0 +1,20 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.extensions.TextReference + +/** + * State of the campaign activation bottom sheet. + * + * The intro promo screen (image + title + description) is always shown. When [selectedToken] is `null` + * the footer shows "Select token"; once a token is chosen it shows the account block, the terms agreement + * and the "Enroll" button. When [isChoosingToken] is `true` the token selector is shown on top of the + * intro (as a stacked bottom sheet), not instead of it. + */ +internal data class ActivateCampaignUM( + val campaignName: String, + val title: TextReference, + val description: TextReference, + val selectedToken: TokenItemState?, + val isChoosingToken: Boolean, +) \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt new file mode 100644 index 0000000000..f3d6574f6d --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignType.kt @@ -0,0 +1,12 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface CampaignType { + + val campaignId: String + + data class ReactivationCashback(override val campaignId: String) : CampaignType + data class WhaleSwapCashback(override val campaignId: String) : CampaignType +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt new file mode 100644 index 0000000000..3c202361c9 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignTypeExt.kt @@ -0,0 +1,7 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +internal fun CampaignType.campaignName(): String = when (this) { + // TODO([REDACTED_TASK_KEY]): source real campaign display names. + is CampaignType.ReactivationCashback -> "Reactivation Cashback" + is CampaignType.WhaleSwapCashback -> "Whale Swap Cashback" +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt new file mode 100644 index 0000000000..50bf5c6cca --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/entity/CampaignsBottomSheetConfig.kt @@ -0,0 +1,20 @@ +package com.tangem.features.promobanners.impl.campaigns.entity + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class CampaignsBottomSheetConfig { + + @Serializable + data object NotActive : CampaignsBottomSheetConfig() + + @Serializable + data class Enrolled( + val campaignType: CampaignType, + ) : CampaignsBottomSheetConfig() + + @Serializable + data class Activate( + val campaignType: CampaignType, + ) : CampaignsBottomSheetConfig() +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt new file mode 100644 index 0000000000..a4774195bf --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/ActivateCampaignsModel.kt @@ -0,0 +1,110 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import com.tangem.common.ui.tokens.TokenItemStateConverter +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.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.features.promobanners.impl.campaigns.entity.campaignName +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class ActivateCampaignsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + chooseTokenBridgeFactory: ChooseTokenBridge.Factory, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, +) : Model() { + + private val params = paramsContainer.require() + + private var appCurrency: AppCurrency = AppCurrency.Default + + val uiState: StateFlow + field = MutableStateFlow(getInitialState()) + + val bridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings( + title = resourceReference(R.string.common_choose_token), + isShowMarketBlock = false, + isShowPaymentAccount = false, + isShowSingleCurrencyWallets = true, + ), + ) + + init { + getSelectedAppCurrencyUseCase.invokeOrDefault() + .onEach { appCurrency = it } + .launchIn(modelScope) + + bridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> onTokenChosen(result) } + .launchIn(modelScope) + + bridge.onClose.receiveAsFlow() + .onEach { onChooseTokenDismiss() } + .launchIn(modelScope) + } + + fun onSelectTokenClick() { + uiState.update { it.copy(isChoosingToken = true) } + } + + fun onChooseTokenDismiss() { + uiState.update { it.copy(isChoosingToken = false) } + } + + fun onEnrollClick() { + // TODO([REDACTED_TASK_KEY]): call the real campaign enrollment use case with the chosen token before proceeding. + params.onActivated(params.campaignType) + } + + fun onDismiss() = params.onDismiss() + + private fun onTokenChosen(result: ChooseTokenResult) { + val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency) + + uiState.update { state -> + state.copy( + isChoosingToken = false, + selectedToken = tokenItem, + ) + } + } + + private fun getInitialState(): ActivateCampaignUM { + return ActivateCampaignUM( + campaignName = params.campaignType.campaignName(), + // TODO([REDACTED_TASK_KEY]): source real campaign copy. + title = stringReference("Enroll in ${params.campaignType.campaignName()}"), + description = stringReference( + "Earn cashback on every swap from \$10K until the end of July.\n\n" + + "Rates step up with size: 0.10% from \$10K, 0.20% from \$20K, 0.50% from \$100K.\n\n", + ), + selectedToken = null, + isChoosingToken = false, + ) + } + + data class Params( + val campaignType: CampaignType, + val onDismiss: () -> Unit, + val onActivated: (CampaignType) -> Unit, + ) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt new file mode 100644 index 0000000000..d0d49dc073 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/model/CampaignsModel.kt @@ -0,0 +1,56 @@ +package com.tangem.features.promobanners.impl.campaigns.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignsBottomSheetConfig +import com.tangem.features.promobanners.impl.campaigns.entity.CampaignType +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +@ModelScoped +internal class CampaignsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + // private val campaignIdConverter: CampaignIdConverter, + // campaignsService: CampaignsService, +) : Model() { + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + init { + // campaignsService.campaignFlow + // .onEach { campaignId -> resolveStartNavigation(campaignIdConverter.convert(campaignId)) } + // .launchIn(modelScope) + } + + @Suppress("UnusedPrivateMember") + private fun resolveStartNavigation(campaignType: CampaignType?) { + val config = when (campaignType) { + is CampaignType.ReactivationCashback -> checkReactivationCashbackCampaignState(campaignType) + is CampaignType.WhaleSwapCashback -> checkWhaleSwapCashbackCampaignState(campaignType) + null -> CampaignsBottomSheetConfig.NotActive + } + + bottomSheetNavigation.activate(config) + } + + // TODO + private fun checkReactivationCashbackCampaignState(campaignType: CampaignType): CampaignsBottomSheetConfig { + return CampaignsBottomSheetConfig.Activate(campaignType) + } + + // TODO + private fun checkWhaleSwapCashbackCampaignState(campaignType: CampaignType): CampaignsBottomSheetConfig { + return CampaignsBottomSheetConfig.Activate(campaignType) + } + + fun onDismiss() { + bottomSheetNavigation.dismiss() + } + + fun onActivated(campaignType: CampaignType) { + bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType)) + } +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt new file mode 100644 index 0000000000..4d3c39aa1c --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignBottomSheet.kt @@ -0,0 +1,50 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM + +@Composable +internal fun ActivateCampaignContent( + state: ActivateCampaignUM, + onSelectTokenClick: () -> Unit, + onEnrollClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onDismiss: () -> Unit, +) { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + onBack = onDismiss, + title = { + TangemTopNavigation( + windowInsets = WindowInsets(0), + blurBackground = false, + endButton = { + TangemButton.Close( + onClick = onDismiss, + ) + }, + ) + }, + content = { + ActivateCampaignContent( + state = state, + onSelectTokenClick = onSelectTokenClick, + onEnrollClick = onEnrollClick, + onLearnMoreClick = onLearnMoreClick, + ) + }, + ) +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignContent.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignContent.kt new file mode 100644 index 0000000000..4ad65c2d5f --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/campaigns/ui/ActivateCampaignContent.kt @@ -0,0 +1,135 @@ +package com.tangem.features.promobanners.impl.campaigns.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.token.TokenItem +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.promobanners.impl.R +import com.tangem.features.promobanners.impl.campaigns.entity.ActivateCampaignUM + +@Composable +internal fun ActivateCampaignContent( + state: ActivateCampaignUM, + onSelectTokenClick: () -> Unit, + onEnrollClick: () -> Unit, + onLearnMoreClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16) + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + // TODO([REDACTED_TASK_KEY]): replace placeholder with the real campaign illustration. + painter = painterResource(R.drawable.ill_businessman_3d), + contentDescription = null, + modifier = Modifier + .size(TangemTheme.dimens.size96) + .clip(CircleShape), + ) + SpacerH16() + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH8() + Text( + text = state.description.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH12() + Text( + // TODO([REDACTED_TASK_KEY]): localize + text = "Learn more", + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.accent, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onLearnMoreClick), + ) + + val selectedToken = state.selectedToken + if (selectedToken != null) { + SpacerH24() + Text( + text = "Select cashback account", // TODO([REDACTED_TASK_KEY]): localize + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH12() + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) + .background(TangemTheme.colors.background.primary) + .padding(vertical = TangemTheme.dimens.spacing12), + ) { + TokenItem(state = selectedToken, isBalanceHidden = false) + } + } + + SpacerH24() + + Footer( + campaignName = state.campaignName, + hasSelectedToken = selectedToken != null, + onSelectTokenClick = onSelectTokenClick, + onEnrollClick = onEnrollClick, + ) + } +} + +@Composable +private fun Footer( + campaignName: String, + hasSelectedToken: Boolean, + onSelectTokenClick: () -> Unit, + onEnrollClick: () -> Unit, +) { + if (hasSelectedToken) { + Text( + text = "I agree with $campaignName Terms", // TODO([REDACTED_TASK_KEY]): localize + clickable terms + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH12() + } + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = if (hasSelectedToken) { + "Enroll" // TODO([REDACTED_TASK_KEY]): localize + } else { + "Select token" // TODO([REDACTED_TASK_KEY]): localize + }, + onClick = if (hasSelectedToken) onEnrollClick else onSelectTokenClick, + ) + SpacerH16() +} \ No newline at end of file diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt index 32414701c1..bc877dedf3 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/di/PromoBannersFeatureModule.kt @@ -5,10 +5,12 @@ import com.tangem.core.decompose.model.Model import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.features.promobanners.api.PromoBannersBlockComponent +import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles import com.tangem.features.promobanners.impl.DefaultPromoBannersBlockComponent import com.tangem.features.promobanners.impl.model.PromoBannersBlockModel import com.tangem.features.promobanners.impl.repository.DefaultPromoBannersRepository import com.tangem.features.promobanners.impl.repository.PromoBannersRepository +import com.tangem.features.promobanners.impl.toggles.DefaultPromoBannersFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Binds import dagger.Module @@ -29,6 +31,10 @@ internal interface PromoBannersFeatureModule { factory: DefaultPromoBannersBlockComponent.Factory, ): PromoBannersBlockComponent.Factory + @Binds + @Singleton + fun bindPromoBannersFeatureToggles(impl: DefaultPromoBannersFeatureToggles): PromoBannersFeatureToggles + companion object { @Provides diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultPromoBannersFeatureToggles.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultPromoBannersFeatureToggles.kt new file mode 100644 index 0000000000..d92298c675 --- /dev/null +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/toggles/DefaultPromoBannersFeatureToggles.kt @@ -0,0 +1,16 @@ +package com.tangem.features.promobanners.impl.toggles + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.promobanners.api.toggles.PromoBannersFeatureToggles +import javax.inject.Inject + +class DefaultPromoBannersFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : PromoBannersFeatureToggles { + + override val isCampaignsToggleEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled( + FeatureToggles.TWI_1637_CASHBACK_AND_REACTIVATION_CAMPAIGNS_ENABLED, + ) +} \ No newline at end of file