Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 15:54:09 +05:00
parent 40c611efea
commit a006a87f9d
27 changed files with 524 additions and 16 deletions

View file

@ -69,6 +69,7 @@ internal class TangemPayDetailsStateFactory(
addToWalletBlockState = null,
errorNotificationConfig = null,
accountDeactivatedNotificationConfig = null,
cashbackBlockState = null,
)
}
@ -120,6 +121,7 @@ internal class TangemPayDetailsStateFactory(
addToWalletBlockState = null,
errorNotificationConfig = errorNotification ?: awaitingDepositNotification,
accountDeactivatedNotificationConfig = null,
cashbackBlockState = null,
)
}
@ -153,6 +155,7 @@ internal class TangemPayDetailsStateFactory(
addToWalletBlockState = null,
errorNotificationConfig = null,
accountDeactivatedNotificationConfig = accountDeactivatedNotification,
cashbackBlockState = null,
)
}

View file

@ -22,8 +22,23 @@ internal data class TangemPayDetailsUM(
val isBalanceHidden: Boolean,
val errorNotificationConfig: NotificationConfig?,
val accountDeactivatedNotificationConfig: NotificationConfig?,
val cashbackBlockState: CashbackBlockUM? = null,
)
@Immutable
internal sealed interface CashbackBlockUM {
data class Widget(
val title: TextReference,
val subtitle: TextReference,
val onClick: () -> Unit,
) : CashbackBlockUM
data class DeactivatedBanner(
val onGotIt: () -> Unit,
) : CashbackBlockUM
}
internal data class TangemPayCardDetailsUM(
val number: String,
val numberShort: String,

View file

@ -36,12 +36,16 @@ import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.pay.usecase.CancelTangemPayOrderUseCase
import com.tangem.domain.pay.usecase.GetCashbackDeactivationDismissedUseCase
import com.tangem.domain.pay.usecase.GetCashbackSummaryUseCase
import com.tangem.domain.pay.usecase.GetCustomerOffersUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.SetCashbackDeactivationDismissedUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.cashback.impl.model.TangemPayCashbackDateFormatter
import com.tangem.features.tangempay.components.AddFundsListener
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayIssueAdditionalCardComponent
@ -88,6 +92,9 @@ internal class TangemPayDetailsModel @Inject constructor(
private val onboardingRepository: OnboardingRepository,
private val getCustomerOffers: GetCustomerOffersUseCase,
private val cancelTangemPayOrderUseCase: CancelTangemPayOrderUseCase,
private val getCashbackSummaryUseCase: GetCashbackSummaryUseCase,
private val getCashbackDeactivationDismissedUseCase: GetCashbackDeactivationDismissedUseCase,
private val setCashbackDeactivationDismissedUseCase: SetCashbackDeactivationDismissedUseCase,
) : Model(),
TangemPayTxHistoryUiActions,
TangemPayDetailIntents,
@ -128,6 +135,8 @@ internal class TangemPayDetailsModel @Inject constructor(
private val refreshStateJobHolder = JobHolder()
private val addToWalletBannerJobHolder = JobHolder()
private val cashbackBlockJobHolder = JobHolder()
private val cashbackDateFormatter = TangemPayCashbackDateFormatter()
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
@ -155,6 +164,7 @@ internal class TangemPayDetailsModel @Inject constructor(
}
is PaymentAccountStatusValue.Loaded -> {
fetchAddToWalletBanner()
fetchCashbackBlock()
val balanceTransformer = DetailsBalanceTransformer(
fiatBalance = state.balance.fiatBalance,
isMuted = !state.isFresh,
@ -265,6 +275,31 @@ internal class TangemPayDetailsModel @Inject constructor(
}.saveIn(addToWalletBannerJobHolder)
}
private fun fetchCashbackBlock() {
if (!tangemPayFeatureToggles.isCashbackEnabled) return
modelScope.launch {
getCashbackSummaryUseCase(userWalletId).onRight { summary ->
val isDismissed = getCashbackDeactivationDismissedUseCase(userWalletId)
uiState.update(
transformer = CashbackBlockTransformer(
summary = summary,
isDeactivationDismissed = isDismissed,
dateFormatter = cashbackDateFormatter,
onClick = ::onClickCashback,
onGotIt = ::onDismissCashbackDeactivation,
),
)
}
}.saveIn(cashbackBlockJobHolder)
}
private fun onDismissCashbackDeactivation() {
modelScope.launch {
setCashbackDeactivationDismissedUseCase(userWalletId)
uiState.update { it.copy(cashbackBlockState = null) }
}.saveIn(cashbackBlockJobHolder)
}
private fun handleBalanceHiding() {
getBalanceHidingSettingsUseCase().onEach {
uiState.update(DetailBalanceVisibilityTransformer(isHidden = it.isBalanceHidden))

View file

@ -0,0 +1,59 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.defaultAmount
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.features.tangempay.cashback.impl.model.TangemPayCashbackDateFormatter
import com.tangem.features.tangempay.entity.CashbackBlockUM
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.utils.transformer.Transformer
/**
* Resolves the Payment account cashback block from a [CashbackSummary]:
* - [CashbackSummary.Enabled] -> tappable [CashbackBlockUM.Widget];
* - [CashbackSummary.Deactivated] (unless dismissed) -> [CashbackBlockUM.DeactivatedBanner];
* - otherwise -> hidden (`null`).
*/
internal class CashbackBlockTransformer(
private val summary: CashbackSummary,
private val isDeactivationDismissed: Boolean,
private val dateFormatter: TangemPayCashbackDateFormatter,
private val onClick: () -> Unit,
private val onGotIt: () -> Unit,
) : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
val block: CashbackBlockUM? = when (summary) {
is CashbackSummary.Enabled -> buildWidget(summary)
CashbackSummary.Deactivated -> if (isDeactivationDismissed) {
null
} else {
CashbackBlockUM.DeactivatedBanner(onGotIt = onGotIt)
}
CashbackSummary.Disabled,
CashbackSummary.Unknown,
-> null
}
return prevState.copy(cashbackBlockState = block)
}
private fun buildWidget(enabled: CashbackSummary.Enabled): CashbackBlockUM.Widget {
val cashback = enabled.cashback
val currency = getJavaCurrencyByCode(cashback.currency)
val amount = cashback.confirmedAmount.format {
fiat(currency.currencyCode, currency.symbol).defaultAmount()
}
val month = dateFormatter.formatMonth(cashback.period.year, cashback.period.month)
val window = dateFormatter.formatWindow(cashback.period.payoutStart, cashback.period.payoutEnd)
return CashbackBlockUM.Widget(
title = resourceReference(R.string.tangempay_cashback_widget_title, wrappedList(amount, month)),
subtitle = resourceReference(R.string.tangempay_cashback_deposited_on, wrappedList(window)),
onClick = onClick,
)
}
}

View file

@ -0,0 +1,109 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.entity.CashbackBlockUM
import com.tangem.core.ui.R as CoreUiR
/**
* Cashback block shown on the Payment account screen V2. Renders either a tappable widget with the
* accrued cashback amount or a warning banner when cashback has been deactivated.
*/
@Composable
internal fun CashbackBlock(state: CashbackBlockUM, modifier: Modifier = Modifier) {
when (state) {
is CashbackBlockUM.Widget -> {
TangemMessage(
modifier = modifier.clickableSingle(onClick = state.onClick),
title = state.title,
subtitle = state.subtitle,
messageEffect = TangemMessageEffect.Magic,
trailingContent = {
Icon(
imageVector = ImageVector.vectorResource(CoreUiR.drawable.ic_chevron_right_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
modifier = Modifier.size(20.dp),
)
},
)
}
is CashbackBlockUM.DeactivatedBanner -> {
TangemMessage(
modifier = modifier,
title = resourceReference(CoreUiR.string.tangempay_cashback_deactivated_title),
subtitle = resourceReference(CoreUiR.string.tangempay_cashback_deactivated_description),
messageEffect = TangemMessageEffect.Warning,
buttons = {
TangemButton(
buttonUM = TangemButtonUM(
text = resourceReference(CoreUiR.string.common_got_it),
onClick = state.onGotIt,
iconPosition = TangemButtonIconPosition.End,
size = TangemButtonSize.X9,
type = TangemButtonType.Primary,
shape = TangemButtonShape.Rounded,
),
modifier = Modifier.weight(1f),
)
},
)
}
}
}
// region preview
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun CashbackBlockWidgetPreview() {
TangemThemePreviewRedesign {
CashbackBlock(
state = CashbackBlockUM.Widget(
title = stringReference("$32.15 cashback in June"),
subtitle = stringReference("Will be deposited on July 25"),
onClick = {},
),
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp)
.fillMaxWidth(),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun CashbackBlockDeactivatedBannerPreview() {
TangemThemePreviewRedesign {
CashbackBlock(
state = CashbackBlockUM.DeactivatedBanner(onGotIt = {}),
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp)
.fillMaxWidth(),
)
}
}
// endregion preview

View file

@ -118,6 +118,15 @@ internal fun TangemPayDetailsScreenV2(
),
) {
payDetailsBody(state)
state.cashbackBlockState?.let { cashbackState ->
item("cashbackBlock") {
SpacerH12()
CashbackBlock(
state = cashbackState,
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
)
}
}
item("promoBannersBlock") {
promoBannersBlockComponent.Content(
modifier = Modifier.padding(vertical = 12.dp),

View file

@ -115,6 +115,9 @@ internal class TangemPayDetailsModelTest {
onboardingRepository = mockk(relaxed = true),
getCustomerOffers = mockk(relaxed = true),
cancelTangemPayOrderUseCase = mockk(relaxed = true),
getCashbackSummaryUseCase = mockk(relaxed = true),
getCashbackDeactivationDismissedUseCase = mockk(relaxed = true),
setCashbackDeactivationDismissedUseCase = mockk(relaxed = true),
)
}

View file

@ -0,0 +1,171 @@
package com.tangem.features.tangempay.model.transformers
import android.text.format.DateFormat
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.pay.model.CashbackDisplayMode
import com.tangem.domain.pay.model.CashbackSummary
import com.tangem.domain.pay.model.TangemPayCashback
import com.tangem.features.tangempay.cashback.impl.model.TangemPayCashbackDateFormatter
import com.tangem.features.tangempay.entity.CashbackBlockUM
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.collections.immutable.persistentListOf
import org.joda.time.DateTime
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
import java.util.Locale
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CashbackBlockTransformerTest {
private val defaultLocale = Locale.getDefault()
private val onClick: () -> Unit = {}
private val onGotIt: () -> Unit = {}
private val dateFormatter = TangemPayCashbackDateFormatter()
@BeforeEach
fun setup() {
// TangemPayCashbackDateFormatter -> DateTimeFormatters.dateMMMM -> android.text.format.DateFormat
Locale.setDefault(Locale.US)
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
}
@AfterEach
fun tearDown() {
unmockkStatic(DateFormat::class)
Locale.setDefault(defaultLocale)
}
@Test
fun `GIVEN enabled summary WHEN transform THEN cashback block is widget with content`() {
// Arrange
val transformer = createTransformer(summary = enabledSummary())
// Act
val block = transformer.transform(contentState()).cashbackBlockState
// Assert
assertThat(block).isInstanceOf(CashbackBlockUM.Widget::class.java)
val widget = block as CashbackBlockUM.Widget
assertThat(widget.title).isInstanceOf(TextReference.Res::class.java)
assertThat(widget.title).isNotEqualTo(TextReference.EMPTY)
assertThat(widget.subtitle).isInstanceOf(TextReference.Res::class.java)
assertThat(widget.subtitle).isNotEqualTo(TextReference.EMPTY)
}
@ParameterizedTest
@MethodSource("provideTestModels")
fun `GIVEN summary and dismissed flag WHEN transform THEN resolves expected cashback block`(model: BlockCase) {
// Arrange
val transformer = createTransformer(summary = model.summary, isDismissed = model.isDismissed)
// Act
val block = transformer.transform(contentState()).cashbackBlockState
// Assert
assertThat(block).isEqualTo(model.expectedBlock)
}
private fun provideTestModels(): List<BlockCase> = listOf(
BlockCase(
description = "deactivated & not dismissed -> banner",
summary = CashbackSummary.Deactivated,
isDismissed = false,
expectedBlock = CashbackBlockUM.DeactivatedBanner(onGotIt = onGotIt),
),
BlockCase(
description = "deactivated & dismissed -> hidden",
summary = CashbackSummary.Deactivated,
isDismissed = true,
expectedBlock = null,
),
BlockCase(
description = "disabled -> hidden",
summary = CashbackSummary.Disabled,
isDismissed = false,
expectedBlock = null,
),
BlockCase(
description = "unknown -> hidden",
summary = CashbackSummary.Unknown,
isDismissed = false,
expectedBlock = null,
),
)
private fun createTransformer(
summary: CashbackSummary,
isDismissed: Boolean = false,
): CashbackBlockTransformer = CashbackBlockTransformer(
summary = summary,
isDeactivationDismissed = isDismissed,
dateFormatter = dateFormatter,
onClick = onClick,
onGotIt = onGotIt,
)
private fun enabledSummary(
confirmedAmount: BigDecimal = BigDecimal("32.15"),
currency: String = "USD",
year: Int = 2026,
month: Int = 6,
payoutStart: DateTime = DateTime.parse("2026-07-02"),
payoutEnd: DateTime = DateTime.parse("2026-07-05"),
): CashbackSummary.Enabled = CashbackSummary.Enabled(
displayMode = CashbackDisplayMode.FULL,
cashback = TangemPayCashback(
confirmedAmount = confirmedAmount,
currency = currency,
period = TangemPayCashback.Period(
year = year,
month = month,
payoutStart = payoutStart,
payoutEnd = payoutEnd,
),
),
pendingAmount = BigDecimal("13.65"),
)
private fun contentState(): TangemPayDetailsUM = TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = {},
onOpenMenu = {},
items = persistentListOf(),
itemsV2 = persistentListOf(),
),
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
balanceBlockState = TangemPayDetailsBalanceBlockState.Content(
actionButtons = persistentListOf(),
cardsBlockState = null,
fiatBalance = TextReference.EMPTY,
isBalanceFlickering = false,
isNegative = false,
),
addToWalletBlockState = null,
isBalanceHidden = false,
errorNotificationConfig = null,
accountDeactivatedNotificationConfig = null,
)
internal class BlockCase(
val summary: CashbackSummary,
val isDismissed: Boolean,
val expectedBlock: CashbackBlockUM?,
private val description: String,
) {
override fun toString(): String = description
}
}