diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CashbackPromotionsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CashbackPromotionsResponse.kt index 4594d721b7..561c64ea44 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CashbackPromotionsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CashbackPromotionsResponse.kt @@ -32,8 +32,10 @@ data class CashbackPromotionsResponse( @JsonClass(generateAdapter = true) data class AdditionalCashback( - @Json(name = "promotion_id") val promotionId: String?, - @Json(name = "label") val label: String?, - @Json(name = "scope") val scope: String?, + @Json(name = "id") val id: String?, + @Json(name = "name") val name: String?, + @Json(name = "description") val description: String?, + @Json(name = "is_permanent") val isPermanent: Boolean?, + @Json(name = "end_date") val endDate: String?, ) } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CashbackPromotionsConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CashbackPromotionsConverter.kt index e695ec1e64..fa82c4b33a 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CashbackPromotionsConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CashbackPromotionsConverter.kt @@ -3,6 +3,7 @@ package com.tangem.data.pay.util import com.tangem.datasource.api.pay.models.response.CashbackPromotionsResponse import com.tangem.domain.pay.model.CashbackPromotions import com.tangem.utils.converter.Converter +import org.joda.time.DateTime /** Maps [CashbackPromotionsResponse] (BFF) to the domain [CashbackPromotions]. */ internal object CashbackPromotionsConverter : Converter { @@ -10,6 +11,7 @@ internal object CashbackPromotionsConverter : Converter = - real.getCustomerInfo(userWalletId) + override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either { + if (isMockMode) return MOCK_CUSTOMER_INFO.right() + return real.getCustomerInfo(userWalletId) + } override suspend fun getBankCredentials( userWalletId: UserWalletId, @@ -100,8 +111,10 @@ internal class MockAwareOnboardingRepository @Inject constructor( real.storeVirtualAccountOrderId(userWalletId, vaOrderId) } - override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either = - real.hasTangemPayInWallet(userWalletId) + override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either { + if (isMockMode) return true.right() + return real.hasTangemPayInWallet(userWalletId) + } override suspend fun checkCustomerEligibility(): List = real.checkCustomerEligibility() @@ -139,5 +152,52 @@ internal class MockAwareOnboardingRepository @Inject constructor( private companion object { const val MOCK_ORDER_ID = "mock-order-id" const val MOCK_VA_ORDER_ID = "mock-va-order-id" + + const val MOCK_CUSTOMER_ID = "mock-customer-id" + const val MOCK_CARD_ID = "mock-card-id" + const val MOCK_PRODUCT_INSTANCE_ID = "mock-product-instance-id" + const val MOCK_CUSTOMER_WALLET_ADDRESS = "0x0000000000000000000000000000000000000002" + const val MOCK_TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" + const val MOCK_POLYGON_CHAIN_ID = 137L + + val MOCK_CUSTOMER_INFO = CustomerInfo( + customerId = MOCK_CUSTOMER_ID, + productInstances = listOf( + CustomerInfo.ProductInstance( + id = MOCK_PRODUCT_INSTANCE_ID, + cardId = MOCK_CARD_ID, + frozenState = TangemPayCardFrozenState.Unfrozen, + displayName = null, + actualCardLimit = null, + adminCardLimit = null, + status = CustomerInfo.ProductInstance.Status.ACTIVE, + specificationDataType = CustomerInfo.ProductInstance.SpecificationDataType.CARD, + ), + ), + cards = listOf( + CustomerInfo.CardInfo( + cardId = MOCK_CARD_ID, + cardStatus = TangemPayCard.Status.ACTIVE, + lastFourDigits = "4242", + isPinSet = true, + images = emptyList(), + ), + ), + kycStatus = KycStatus.APPROVED, + state = CustomerInfo.State.ACTIVE, + fiatBalance = PaymentAccountStatusValue.FiatBalance( + availableBalance = BigDecimal("123.45"), + currency = "USD", + ), + cryptoBalance = PaymentAccountStatusValue.CryptoBalance( + id = "usd-coin", + chainId = MOCK_POLYGON_CHAIN_ID, + depositAddress = MOCK_CUSTOMER_WALLET_ADDRESS, + tokenContractAddress = MOCK_TOKEN_CONTRACT_ADDRESS, + balance = BigDecimal("123.45"), + ), + availableForWithdrawal = BigDecimal("123.45"), + tariffPlan = null, + ) } } \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/util/CashbackPromotionsConverterTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/util/CashbackPromotionsConverterTest.kt index 5a2a342f14..9f066d9e95 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/util/CashbackPromotionsConverterTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/util/CashbackPromotionsConverterTest.kt @@ -3,6 +3,7 @@ package com.tangem.data.pay.util import com.google.common.truth.Truth.assertThat import com.tangem.datasource.api.pay.models.response.CashbackPromotionsResponse import com.tangem.domain.pay.model.CashbackPromotions +import org.joda.time.DateTime import org.junit.jupiter.api.Test import java.math.BigDecimal @@ -28,6 +29,87 @@ internal class CashbackPromotionsConverterTest { monthlyCapAmount = BigDecimal("100"), ), ), + additionalCashback = emptyList(), + ), + ) + } + + @Test + fun `GIVEN additional cashback WHEN convert THEN each promo mapped with its fields`() { + // Arrange + val response = CashbackPromotionsResponse( + cashbackOnCards = null, + additionalCashback = listOf( + additional(id = "p1", name = "Groceries", description = "+1%", isPermanent = true, endDate = null), + additional( + id = "p2", + name = "Cashback", + description = "+2%", + isPermanent = false, + endDate = "2026-09-26", + ), + ), + ) + + // Act + val result = CashbackPromotionsConverter.convert(response) + + // Assert + assertThat(result.additionalCashback).containsExactly( + CashbackPromotions.AdditionalCashback( + id = "p1", + name = "Groceries", + description = "+1%", + isPermanent = true, + endDate = null, + ), + CashbackPromotions.AdditionalCashback( + id = "p2", + name = "Cashback", + description = "+2%", + isPermanent = false, + endDate = DateTime.parse("2026-09-26"), + ), + ).inOrder() + } + + @Test + fun `GIVEN additional cashback with null isPermanent WHEN convert THEN it is derived from end date`() { + // Arrange + val response = CashbackPromotionsResponse( + cashbackOnCards = null, + additionalCashback = listOf( + additional(isPermanent = null, endDate = null), + additional(isPermanent = null, endDate = "2026-09-26"), + ), + ) + + // Act + val result = CashbackPromotionsConverter.convert(response) + + // Assert + assertThat(result.additionalCashback.map { it.isPermanent }).containsExactly(true, false).inOrder() + } + + @Test + fun `GIVEN additional cashback with malformed end date WHEN convert THEN end date null and payload kept`() { + // Arrange + val response = CashbackPromotionsResponse( + cashbackOnCards = null, + additionalCashback = listOf(additional(isPermanent = false, endDate = "not-a-date")), + ) + + // Act + val result = CashbackPromotionsConverter.convert(response) + + // Assert + assertThat(result.additionalCashback).containsExactly( + CashbackPromotions.AdditionalCashback( + id = "id", + name = "name", + description = "description", + isPermanent = false, + endDate = null, ), ) } @@ -87,4 +169,18 @@ internal class CashbackPromotionsConverterTest { tierMonthlyCapAmount = cap, promotionId = null, ) + + private fun additional( + id: String? = "id", + name: String? = "name", + description: String? = "description", + isPermanent: Boolean? = false, + endDate: String? = null, + ) = CashbackPromotionsResponse.AdditionalCashback( + id = id, + name = name, + description = description, + isPermanent = isPermanent, + endDate = endDate, + ) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CashbackPromotions.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CashbackPromotions.kt index bc829c5a5b..3af07174f7 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CashbackPromotions.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CashbackPromotions.kt @@ -1,10 +1,12 @@ package com.tangem.domain.pay.model +import org.joda.time.DateTime import java.math.BigDecimal /** Cashback program configuration for the customer, from `GET v1/customer/cashback/promotions`. */ data class CashbackPromotions( val cardTiers: List, + val additionalCashback: List, ) { data class CardTier( @@ -14,4 +16,12 @@ data class CashbackPromotions( val minTransactionAmount: BigDecimal?, val monthlyCapAmount: BigDecimal?, ) + + data class AdditionalCashback( + val id: String, + val name: String, + val description: String, + val isPermanent: Boolean, + val endDate: DateTime?, + ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayAdditionalCashbackConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayAdditionalCashbackConverter.kt new file mode 100644 index 0000000000..2917e3ef5e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayAdditionalCashbackConverter.kt @@ -0,0 +1,37 @@ +package com.tangem.features.tangempay.cashback.impl.model + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.pay.model.CashbackPromotions +import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayAdditionalCashbackUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +internal class TangemPayAdditionalCashbackConverter( + private val dateFormatter: TangemPayCashbackDateFormatter = TangemPayCashbackDateFormatter(), +) : Converter, TangemPayAdditionalCashbackUM> { + + // TODO([REDACTED_TASK_KEY]): move hardcoded strings to string resources + override fun convert(value: List): TangemPayAdditionalCashbackUM { + return TangemPayAdditionalCashbackUM( + items = value.map { promo -> + TangemPayAdditionalCashbackUM.Item( + id = promo.id, + name = stringReference(promo.name), + description = stringReference(promo.description), + badge = promo.toBadge(), + ) + }.toImmutableList(), + ) + } + + private fun CashbackPromotions.AdditionalCashback.toBadge(): TangemPayAdditionalCashbackUM.Badge { + val expiry = endDate + return if (isPermanent || expiry == null) { + TangemPayAdditionalCashbackUM.Badge.Permanent + } else { + TangemPayAdditionalCashbackUM.Badge.Until( + text = stringReference("Until ${dateFormatter.formatNumericDate(expiry)}"), + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatter.kt index 6b8eb416ae..eb464cf19b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatter.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatter.kt @@ -13,6 +13,8 @@ internal class TangemPayCashbackDateFormatter { fun formatMonthDay(date: DateTime): String = DateTimeFormatters.formatDate(date, DateTimeFormatters.dateMMMMd) + fun formatNumericDate(date: DateTime): String = DateTimeFormatters.formatDate(date, DateTimeFormatters.dateDDMMYYYY) + fun formatWindow(start: DateTime, end: DateTime): String { val isSameMonth = start.year == end.year && start.monthOfYear == end.monthOfYear return if (isSameMonth) { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModel.kt index c27354104e..f08c68509c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModel.kt @@ -21,6 +21,7 @@ import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackDet import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackScreenUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -45,9 +46,10 @@ internal class TangemPayCashbackModel @Inject constructor( val bottomSheetNavigation: SlotNavigation = SlotNavigation() - private val cashbackConverter = TangemPayCashbackUmConverter(onCloseClick = router::pop) + private val cashbackConverter = TangemPayCashbackUmConverter() private val histogramConverter = TangemPayCashbackHistogramConverter() private val tiersConverter = TangemPayCashbackTiersConverter() + private val additionalCashbackConverter = TangemPayAdditionalCashbackConverter() private val infoTilesConverter = TangemPayCashbackInfoTilesConverter( onRateClick = { bottomSheetNavigation.activate(TangemPayCashbackNavigation.Details) }, onAccrualsClick = { bottomSheetNavigation.activate(TangemPayCashbackNavigation.Accruals) }, @@ -62,40 +64,50 @@ internal class TangemPayCashbackModel @Inject constructor( field = MutableStateFlow(accrualsConverter.convert(emptyList())) val uiState: StateFlow - field = MutableStateFlow( - TangemPayCashbackScreenUM( - cashback = cashbackConverter.convert(value = null), - infoTiles = null, - histogram = null, - ), + field = MutableStateFlow( + TangemPayCashbackScreenUM.Loading(onCloseClick = router::pop), ) + private var loadJob: Job? = null + init { loadCashback() } private fun loadCashback() { - modelScope.launch { + loadJob?.cancel() + uiState.value = TangemPayCashbackScreenUM.Loading(onCloseClick = router::pop) + loadJob = modelScope.launch { val summaryDeferred = async { loadSummary() } val promotionsDeferred = async { loadPromotions() } val docsDeferred = async { loadDocs() } val planDeferred = async { loadPlan() } val summary = summaryDeferred.await() - val history = if (summary is CashbackSummary.Enabled) loadHistory() else null val promotions = promotionsDeferred.await() + + if (summary == null && promotions == null) { + uiState.value = TangemPayCashbackScreenUM.Error( + onCloseClick = router::pop, + onReloadClick = ::loadCashback, + ) + return@launch + } + + val history = if (summary is CashbackSummary.Enabled) loadHistory() else null val plan = planDeferred.await() val tiers = promotions?.let(tiersConverter::convert).orEmpty() - uiState.value = TangemPayCashbackScreenUM( + uiState.value = TangemPayCashbackScreenUM.Content( + onCloseClick = router::pop, cashback = cashbackConverter.convert((summary as? CashbackSummary.Enabled)?.cashback), infoTiles = promotions?.let { - infoTilesConverter.convert( - tiers = tiers, - currentPlan = plan, - ) + infoTilesConverter.convert(tiers = tiers, currentPlan = plan) }, histogram = history?.takeIf { it.months.isNotEmpty() }?.let(histogramConverter::convert), + additionalCashback = promotions + ?.let { additionalCashbackConverter.convert(it.additionalCashback) } + ?.takeIf { it.items.isNotEmpty() }, ) detailsSheet.value = detailsConverter.convert(tiers) accrualsSheet.value = accrualsConverter.convert(docsDeferred.await()) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverter.kt index 35bbfafb62..e3f25790d3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverter.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverter.kt @@ -10,7 +10,6 @@ import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackUM import com.tangem.utils.converter.Converter internal class TangemPayCashbackUmConverter( - private val onCloseClick: () -> Unit, private val dateFormatter: TangemPayCashbackDateFormatter = TangemPayCashbackDateFormatter(), ) : Converter { @@ -22,7 +21,6 @@ internal class TangemPayCashbackUmConverter( subtitle = stringReference("Collected amount will be shown here"), isEmpty = true, banner = null, - onCloseClick = onCloseClick, ) } val currency = getJavaCurrencyByCode(value.currency) @@ -48,7 +46,6 @@ internal class TangemPayCashbackUmConverter( subtitle = stringReference("Will be deposited on $payoutWindow"), isEmpty = false, banner = banner, - onCloseClick = onCloseClick, ) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayAdditionalCashback.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayAdditionalCashback.kt new file mode 100644 index 0000000000..2dc8d4b948 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayAdditionalCashback.kt @@ -0,0 +1,145 @@ +package com.tangem.features.tangempay.cashback.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.badge.TangemBadge +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayAdditionalCashbackUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun TangemPayAdditionalCashback(state: TangemPayAdditionalCashbackUM, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(16.dp), + ) { + Text( + // TODO([REDACTED_TASK_KEY]): move to string resources + text = stringReference("Additional cashback").resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.items.forEach { item -> + AdditionalCashbackCard(item = item) + } + } + } +} + +@Composable +private fun AdditionalCashbackCard(item: TangemPayAdditionalCashbackUM.Item, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors3.bg.secondary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CashbackBadge(badge = item.badge) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = item.name.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + Text( + text = item.description.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + ) + } + } +} + +@Composable +private fun CashbackBadge(badge: TangemPayAdditionalCashbackUM.Badge, modifier: Modifier = Modifier) { + when (badge) { + TangemPayAdditionalCashbackUM.Badge.Permanent -> TangemBadge( + // TODO([REDACTED_TASK_KEY]): move to string resources + text = stringReference("Permanent"), + modifier = modifier, + status = TangemBadge.Status.Neutral, + size = TangemBadge.Size.X6, + ) + is TangemPayAdditionalCashbackUM.Badge.Until -> TangemBadge( + text = badge.text, + modifier = modifier, + status = TangemBadge.Status.Info, + size = TangemBadge.Size.X6, + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_clock_24), + ) + } +} + +@Preview(showBackground = true, widthDp = 402) +@Preview(showBackground = true, widthDp = 402, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayAdditionalCashbackPreview( + @PreviewParameter(TangemPayAdditionalCashbackPreviewProvider::class) state: TangemPayAdditionalCashbackUM, +) { + TangemThemePreviewRedesign { + TangemPayAdditionalCashback( + state = state, + modifier = Modifier.background(TangemTheme.colors3.bg.primary), + ) + } +} + +private class TangemPayAdditionalCashbackPreviewProvider : + CollectionPreviewParameterProvider( + listOf( + TangemPayAdditionalCashbackUM( + items = persistentListOf( + TangemPayAdditionalCashbackUM.Item( + id = "1", + name = stringReference("Groceries increase"), + description = stringReference("+1% cashback for groceries stores"), + badge = TangemPayAdditionalCashbackUM.Badge.Permanent, + ), + TangemPayAdditionalCashbackUM.Item( + id = "2", + name = stringReference("Groceries increase"), + description = stringReference("+1% cashback for groceries stores. Max \$10/month"), + badge = TangemPayAdditionalCashbackUM.Badge.Until(stringReference("Until 09.26.2026")), + ), + TangemPayAdditionalCashbackUM.Item( + id = "3", + name = stringReference("Cashback increase"), + description = stringReference("+2% cashback for groceries stores. Max \$10/month"), + badge = TangemPayAdditionalCashbackUM.Badge.Until(stringReference("Until 09.26.2026")), + ), + ), + ), + ), + ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackScreen.kt index 2331337c08..c9746f0842 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackScreen.kt @@ -2,15 +2,23 @@ package com.tangem.features.tangempay.cashback.impl.ui import android.content.res.Configuration import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars 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 @@ -24,19 +32,23 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem +import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.LocalIsInDarkTheme import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayAdditionalCashbackUM import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackHistogramUM.Style import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackInfoTilesUM @@ -50,52 +62,114 @@ private const val GLOW_RADIUS_FACTOR = 0.585f private const val GLOW_BLUE_ALPHA = 0.20f private const val GLOW_WARM_ALPHA = 0.15f +private val TopBarHeight: Dp = 68.dp + @Composable internal fun TangemPayCashbackScreen(state: TangemPayCashbackScreenUM, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxSize() - .background(TangemTheme.colors3.bg.primary), - ) { - if (state.cashback.isEmpty) { - EmptyStateGlow(modifier = Modifier.fillMaxSize()) - } - Column(modifier = Modifier.fillMaxSize()) { - TangemTopNavigation( - // TODO([REDACTED_TASK_KEY]): move to string resources - title = stringReference("Cashback"), - contentAlign = TangemTopNavigation.ContentAlign.Center, - onClose = state.cashback.onCloseClick, - ) - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - ) { - HeroBlock(state = state.cashback) - state.cashback.banner?.let { banner -> - CashbackBanner( - banner = banner, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - } - state.infoTiles?.let { infoTiles -> - TangemPayCashbackInfoTiles( - state = infoTiles, - modifier = Modifier.padding(top = 24.dp), - ) - } - state.histogram?.let { histogram -> - TangemPayCashbackHistogram( - state = histogram, - modifier = Modifier.padding(top = 24.dp), - ) - } + val topPadding = TopBarHeight + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .hazeSourceTangem() + .background(TangemTheme.colors3.bg.primary), + ) { + if (state is TangemPayCashbackScreenUM.Content && state.cashback.isEmpty) { + EmptyStateGlow(modifier = Modifier.fillMaxSize()) + } + + when (state) { + is TangemPayCashbackScreenUM.Loading -> TangemPayCashbackShimmer( + modifier = Modifier + .fillMaxSize() + .padding(top = topPadding), + ) + is TangemPayCashbackScreenUM.Error -> CashbackError( + onReloadClick = state.onReloadClick, + modifier = Modifier + .fillMaxSize() + .padding(top = topPadding) + .navigationBarsPadding(), + ) + is TangemPayCashbackScreenUM.Content -> CashbackContent( + state = state, + topPadding = topPadding, + modifier = Modifier.fillMaxSize(), + ) } } + + TangemTopNavigation( + // TODO([REDACTED_TASK_KEY]): move to string resources + title = stringReference("Cashback"), + contentAlign = TangemTopNavigation.ContentAlign.Center, + onClose = state.onCloseClick, + ) + } +} + +@Composable +private fun CashbackContent(state: TangemPayCashbackScreenUM.Content, topPadding: Dp, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(top = topPadding, bottom = 16.dp) + .navigationBarsPadding(), + ) { + HeroBlock(state = state.cashback) + state.cashback.banner?.let { banner -> + CashbackBanner( + banner = banner, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + state.infoTiles?.let { infoTiles -> + TangemPayCashbackInfoTiles(state = infoTiles, modifier = Modifier.padding(top = 24.dp)) + } + state.histogram?.let { histogram -> + TangemPayCashbackHistogram(state = histogram, modifier = Modifier.padding(top = 24.dp)) + } + state.additionalCashback?.let { additionalCashback -> + TangemPayAdditionalCashback(state = additionalCashback, modifier = Modifier.padding(top = 24.dp)) + } + } +} + +@Composable +private fun CashbackError(onReloadClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(horizontal = 64.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.inverse) + .clickable(role = Role.Button, onClick = onReloadClick) + .padding(10.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(CoreUiR.drawable.ic_refresh_24), + // TODO([REDACTED_TASK_KEY]): move to string resources + contentDescription = stringReference("Reload").resolveReference(), + tint = TangemTheme.colors3.icon.inverse, + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + // TODO([REDACTED_TASK_KEY]): move to string resources + text = stringReference("Failed to load page.\nTap to reload").resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + ) } } @@ -199,7 +273,8 @@ private fun TangemPayCashbackScreenPreview( private class TangemPayCashbackScreenUMProvider : CollectionPreviewParameterProvider( collection = listOf( - TangemPayCashbackScreenUM( + TangemPayCashbackScreenUM.Content( + onCloseClick = {}, cashback = TangemPayCashbackUM( title = stringReference("$22.54 earned in June"), subtitle = stringReference("Will be deposited on July 1–5"), @@ -208,38 +283,25 @@ private class TangemPayCashbackScreenUMProvider : CollectionPreviewParameterProv text = stringReference("Cashback $22.54 for June will be deposited till July 5"), type = TangemPayCashbackUM.Banner.Type.Info, ), - onCloseClick = {}, ), infoTiles = previewInfoTiles(), histogram = previewHistogram(), + additionalCashback = previewAdditionalCashback(), ), - TangemPayCashbackScreenUM( - cashback = TangemPayCashbackUM( - title = stringReference("$22.54 earned in June"), - subtitle = stringReference("Will be deposited on July 1–5"), - isEmpty = false, - banner = TangemPayCashbackUM.Banner( - text = stringReference( - "We received a refund for a purchase for which cashback had previously been awarded", - ), - type = TangemPayCashbackUM.Banner.Type.Error, - ), - onCloseClick = {}, - ), - infoTiles = null, - histogram = null, - ), - TangemPayCashbackScreenUM( + TangemPayCashbackScreenUM.Content( + onCloseClick = {}, cashback = TangemPayCashbackUM( title = stringReference("Start spending and earn cashback"), subtitle = stringReference("Collected amount will be shown here"), isEmpty = true, banner = null, - onCloseClick = {}, ), infoTiles = null, histogram = null, + additionalCashback = null, ), + TangemPayCashbackScreenUM.Loading(onCloseClick = {}), + TangemPayCashbackScreenUM.Error(onCloseClick = {}, onReloadClick = {}), ), ) @@ -276,4 +338,21 @@ private fun previewHistogram(): TangemPayCashbackHistogramUM { bar(month = "Jun", amount = "$32.15", value = 32.15f, style = Style.Highlighted), ), ) -} \ No newline at end of file +} + +private fun previewAdditionalCashback() = TangemPayAdditionalCashbackUM( + items = persistentListOf( + TangemPayAdditionalCashbackUM.Item( + id = "1", + name = stringReference("Groceries increase"), + description = stringReference("+1% cashback for groceries stores"), + badge = TangemPayAdditionalCashbackUM.Badge.Permanent, + ), + TangemPayAdditionalCashbackUM.Item( + id = "2", + name = stringReference("Cashback increase"), + description = stringReference("+2% cashback for groceries stores. Max \$10/month"), + badge = TangemPayAdditionalCashbackUM.Badge.Until(stringReference("Until 09.26.2026")), + ), + ), +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackShimmer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackShimmer.kt new file mode 100644 index 0000000000..a4c1161813 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackShimmer.kt @@ -0,0 +1,143 @@ +package com.tangem.features.tangempay.cashback.impl.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer +import com.tangem.core.ui.ds2.shimmers.TangemShimmer +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +internal fun TangemPayCashbackShimmer(modifier: Modifier = Modifier) { + ProvideTangemShimmer { + Column(modifier = modifier.fillMaxWidth()) { + HeroShimmer() + InfoTilesShimmer(modifier = Modifier.padding(top = 24.dp)) + HistogramShimmer(modifier = Modifier.padding(top = 24.dp)) + AdditionalCashbackShimmer(modifier = Modifier.padding(top = 24.dp)) + } + } +} + +@Composable +private fun HeroShimmer(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(vertical = 48.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemShimmer(style = TangemTheme.typography3.heading.medium, textAlign = TextAlign.Center) + TangemShimmer(style = TangemTheme.typography3.subheading.medium, textAlign = TextAlign.Center) + } +} + +@Composable +private fun InfoTilesShimmer(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TangemShimmer(modifier = Modifier.weight(1f).height(108.dp), radius = 16.dp) + TangemShimmer(modifier = Modifier.weight(1f).height(108.dp), radius = 16.dp) + } +} + +@Composable +private fun HistogramShimmer(modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + SectionHeaderShimmer() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Bottom, + ) { + BAR_HEIGHTS.forEach { barHeight -> + TangemShimmer(modifier = Modifier.weight(1f).height(barHeight), radius = 8.dp) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(TangemTheme.colors3.border.primary), + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + repeat(BAR_HEIGHTS.size) { + TangemShimmer( + style = TangemTheme.typography3.caption.medium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun AdditionalCashbackShimmer(modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + SectionHeaderShimmer() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(times = 3) { + TangemShimmer(modifier = Modifier.fillMaxWidth().height(108.dp), radius = 24.dp) + } + } + } +} + +@Composable +private fun SectionHeaderShimmer(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(16.dp), + ) { + TangemShimmer(style = TangemTheme.typography3.heading.small) + } +} + +private val BAR_HEIGHTS: List = listOf(38.dp, 115.dp, 102.dp, 59.dp, 77.dp) + +@Preview(showBackground = true, widthDp = 402) +@Preview(showBackground = true, widthDp = 402, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayCashbackShimmerPreview() { + TangemThemePreviewRedesign { + TangemPayCashbackShimmer(modifier = Modifier.background(TangemTheme.colors3.bg.primary)) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayAdditionalCashbackUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayAdditionalCashbackUM.kt new file mode 100644 index 0000000000..f13732a414 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayAdditionalCashbackUM.kt @@ -0,0 +1,40 @@ +package com.tangem.features.tangempay.cashback.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +/** + * State for the "Additional cashback" section on the Cashback screen. + * + * @property items one card per active additional/bonus promotion, ordered as returned by the BFF + */ +@Immutable +internal data class TangemPayAdditionalCashbackUM( + val items: ImmutableList, +) { + + /** + * @property name short promotion name shown above the description + * @property description one-line promotion description + * @property badge validity badge — permanent or time-limited + */ + @Immutable + data class Item( + val id: String, + val name: TextReference, + val description: TextReference, + val badge: Badge, + ) + + /** Validity badge shown at the top of an additional-cashback card. */ + @Immutable + sealed interface Badge { + + /** No expiry — rendered as a neutral "Permanent" pill. */ + data object Permanent : Badge + + /** Time-limited — rendered as an info "Until " pill with a clock icon. */ + data class Until(val text: TextReference) : Badge + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackScreenUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackScreenUM.kt index 466b74d6d9..24f0f66726 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackScreenUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackScreenUM.kt @@ -2,9 +2,29 @@ package com.tangem.features.tangempay.cashback.impl.ui.state import androidx.compose.runtime.Immutable +/** Top-level state of the Cashback screen. The top navigation (title + close) is shown in every state. */ @Immutable -internal data class TangemPayCashbackScreenUM( - val cashback: TangemPayCashbackUM, - val infoTiles: TangemPayCashbackInfoTilesUM?, - val histogram: TangemPayCashbackHistogramUM?, -) \ No newline at end of file +internal sealed interface TangemPayCashbackScreenUM { + + val onCloseClick: () -> Unit + + /** Skeleton placeholder shown while the screen data is loading. */ + data class Loading( + override val onCloseClick: () -> Unit, + ) : TangemPayCashbackScreenUM + + /** The page failed to load; the user can tap to retry. */ + data class Error( + override val onCloseClick: () -> Unit, + val onReloadClick: () -> Unit, + ) : TangemPayCashbackScreenUM + + /** Loaded content. Each section is nullable and hidden when its data is unavailable. */ + data class Content( + override val onCloseClick: () -> Unit, + val cashback: TangemPayCashbackUM, + val infoTiles: TangemPayCashbackInfoTilesUM?, + val histogram: TangemPayCashbackHistogramUM?, + val additionalCashback: TangemPayAdditionalCashbackUM?, + ) : TangemPayCashbackScreenUM +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackUM.kt index 3047882a69..fbf48d7c71 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackUM.kt @@ -9,7 +9,6 @@ internal data class TangemPayCashbackUM( val subtitle: TextReference, val isEmpty: Boolean, val banner: Banner?, - val onCloseClick: () -> Unit, ) { @Immutable diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayAdditionalCashbackConverterTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayAdditionalCashbackConverterTest.kt new file mode 100644 index 0000000000..72e299cef8 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayAdditionalCashbackConverterTest.kt @@ -0,0 +1,103 @@ +package com.tangem.features.tangempay.cashback.impl.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.pay.model.CashbackPromotions +import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayAdditionalCashbackUM +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.joda.time.DateTime +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 + +private const val FORMATTED_DATE = "26.09.2026" + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayAdditionalCashbackConverterTest { + + private val dateFormatter: TangemPayCashbackDateFormatter = mockk() + private val converter = TangemPayAdditionalCashbackConverter(dateFormatter) + + @BeforeEach + fun setup() { + clearMocks(dateFormatter) + every { dateFormatter.formatNumericDate(any()) } returns FORMATTED_DATE + } + + @Test + fun `GIVEN empty list WHEN convert THEN no items`() { + // Act + val result = converter.convert(emptyList()) + + // Assert + assertThat(result.items).isEmpty() + } + + @Test + fun `GIVEN promo WHEN convert THEN item fields mapped`() { + // Arrange + val promo = additional(id = "1", name = "Groceries increase", description = "+1%", isPermanent = true) + + // Act + val result = converter.convert(listOf(promo)) + + // Assert + assertThat(result.items).containsExactly( + TangemPayAdditionalCashbackUM.Item( + id = "1", + name = stringReference("Groceries increase"), + description = stringReference("+1%"), + badge = TangemPayAdditionalCashbackUM.Badge.Permanent, + ), + ) + } + + @ParameterizedTest + @MethodSource("badgeCases") + fun `GIVEN permanence and end date WHEN convert THEN badge reflects them`(model: BadgeCase) { + // Act + val result = converter.convert(listOf(additional(isPermanent = model.isPermanent, endDate = model.endDate))) + + // Assert + assertThat(result.items.single().badge).isEqualTo(model.expected) + } + + private fun badgeCases() = listOf( + BadgeCase(isPermanent = true, endDate = null, expected = TangemPayAdditionalCashbackUM.Badge.Permanent), + BadgeCase(isPermanent = true, endDate = DATE, expected = TangemPayAdditionalCashbackUM.Badge.Permanent), + BadgeCase(isPermanent = false, endDate = null, expected = TangemPayAdditionalCashbackUM.Badge.Permanent), + BadgeCase( + isPermanent = false, + endDate = DATE, + expected = TangemPayAdditionalCashbackUM.Badge.Until(stringReference("Until $FORMATTED_DATE")), + ), + ) + + internal data class BadgeCase( + val isPermanent: Boolean, + val endDate: DateTime?, + val expected: TangemPayAdditionalCashbackUM.Badge, + ) + + private fun additional( + id: String = "id", + name: String = "name", + description: String = "description", + isPermanent: Boolean = false, + endDate: DateTime? = null, + ) = CashbackPromotions.AdditionalCashback( + id = id, + name = name, + description = description, + isPermanent = isPermanent, + endDate = endDate, + ) + + private companion object { + val DATE: DateTime = DateTime.parse("2026-09-26") + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatterTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatterTest.kt index 1fed0398d3..65efa97ecd 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatterTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatterTest.kt @@ -67,4 +67,13 @@ internal class TangemPayCashbackDateFormatterTest { // Assert assertThat(actual).isEqualTo("July 30 – August 2") } + + @Test + fun `GIVEN date WHEN formatNumericDate THEN numeric day month year`() { + // Act + val actual = formatter.formatNumericDate(DateTime.parse("2026-09-26")) + + // Assert + assertThat(actual).isEqualTo("26.09.2026") + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModelTest.kt index 208e902811..ece9803673 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModelTest.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.cashback.impl.model import android.text.format.DateFormat +import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.core.decompose.model.MutableParamsContainer @@ -19,7 +20,9 @@ import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.TangemPayCashback import com.tangem.domain.pay.repository.CashbackRepository import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent +import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackScreenUM import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery @@ -70,8 +73,8 @@ internal class TangemPayCashbackModelTest { val model = createModel() // Assert - assertThat(model.uiState.value.infoTiles).isNotNull() - assertThat(model.uiState.value.infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 1%")) + assertThat(model.content().infoTiles).isNotNull() + assertThat(model.content().infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 1%")) assertThat(model.detailsSheet.value.rows).hasSize(2) assertThat(model.accrualsSheet.value.docRows).hasSize(2) } @@ -86,7 +89,7 @@ internal class TangemPayCashbackModelTest { val model = createModel() // Assert - assertThat(model.uiState.value.infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 2%")) + assertThat(model.content().infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 2%")) } @Test @@ -98,7 +101,7 @@ internal class TangemPayCashbackModelTest { val model = createModel() // Assert - assertThat(model.uiState.value.infoTiles).isNull() + assertThat(model.content().infoTiles).isNull() assertThat(model.detailsSheet.value.rows).isEmpty() } @@ -111,10 +114,53 @@ internal class TangemPayCashbackModelTest { val model = createModel() // Assert - assertThat(model.uiState.value.infoTiles).isNotNull() + assertThat(model.content().infoTiles).isNotNull() assertThat(model.detailsSheet.value.rows).hasSize(2) } + @Test + fun `GIVEN summary and promotions both fail WHEN model created THEN error state`() { + // Arrange + coEvery { cashbackRepository.getCashbackSummary(any()) } throws RuntimeException("boom") + coEvery { cashbackRepository.getCashbackPromotions(any()) } throws RuntimeException("boom") + + // Act + val model = createModel() + + // Assert + assertThat(model.uiState.value).isInstanceOf(TangemPayCashbackScreenUM.Error::class.java) + } + + @Test + fun `GIVEN summary and promotions both return error WHEN model created THEN error state`() { + // Arrange + coEvery { cashbackRepository.getCashbackSummary(any()) } returns VisaApiError.Unspecified.left() + coEvery { cashbackRepository.getCashbackPromotions(any()) } returns VisaApiError.Unspecified.left() + + // Act + val model = createModel() + + // Assert + assertThat(model.uiState.value).isInstanceOf(TangemPayCashbackScreenUM.Error::class.java) + } + + @Test + fun `GIVEN error state WHEN reload succeeds THEN content shown`() { + // Arrange + coEvery { cashbackRepository.getCashbackSummary(any()) } throws RuntimeException("boom") + coEvery { cashbackRepository.getCashbackPromotions(any()) } throws RuntimeException("boom") + val model = createModel() + val error = model.uiState.value as TangemPayCashbackScreenUM.Error + coEvery { cashbackRepository.getCashbackSummary(any()) } returns CashbackSummary.Disabled.right() + coEvery { cashbackRepository.getCashbackPromotions(any()) } returns promotions().right() + + // Act + error.onReloadClick() + + // Assert + assertThat(model.uiState.value).isInstanceOf(TangemPayCashbackScreenUM.Content::class.java) + } + @Test fun `GIVEN customer info fails WHEN model created THEN rate tile falls back to the first tier`() { // Arrange @@ -124,7 +170,7 @@ internal class TangemPayCashbackModelTest { val model = createModel() // Assert - assertThat(model.uiState.value.infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 1%")) + assertThat(model.content().infoTiles?.rate?.title).isEqualTo(stringReference("Cashback 1%")) } @Test @@ -140,6 +186,28 @@ internal class TangemPayCashbackModelTest { assertThat(model.accrualsSheet.value.infoRows).isNotEmpty() } + @Test + fun `GIVEN additional cashback WHEN model created THEN additional cashback section populated`() { + // Arrange + coEvery { cashbackRepository.getCashbackPromotions(any()) } returns + promotions(additional = listOf(additionalPromo())).right() + + // Act + val model = createModel() + + // Assert + assertThat(model.content().additionalCashback?.items).hasSize(1) + } + + @Test + fun `GIVEN no additional cashback WHEN model created THEN additional cashback section hidden`() { + // Act + val model = createModel() + + // Assert + assertThat(model.content().additionalCashback).isNull() + } + @Test fun `GIVEN enabled summary and history WHEN model created THEN histogram populated`() { // Arrange @@ -150,8 +218,8 @@ internal class TangemPayCashbackModelTest { val model = createModel() // Assert - assertThat(model.uiState.value.histogram).isNotNull() - assertThat(model.uiState.value.histogram?.bars).hasSize(2) + assertThat(model.content().histogram).isNotNull() + assertThat(model.content().histogram?.bars).hasSize(2) } @Test @@ -160,7 +228,7 @@ internal class TangemPayCashbackModelTest { val model = createModel() // Assert - assertThat(model.uiState.value.histogram).isNull() + assertThat(model.content().histogram).isNull() coVerify(exactly = 0) { cashbackRepository.getCashbackHistory(any(), any()) } } @@ -173,7 +241,12 @@ internal class TangemPayCashbackModelTest { onboardingRepository = onboardingRepository, ) - private fun promotions() = CashbackPromotions( + private fun TangemPayCashbackModel.content(): TangemPayCashbackScreenUM.Content = + uiState.value as TangemPayCashbackScreenUM.Content + + private fun promotions( + additional: List = emptyList(), + ) = CashbackPromotions( cardTiers = listOf( CashbackPromotions.CardTier( tier = "basic", @@ -190,6 +263,15 @@ internal class TangemPayCashbackModelTest { monthlyCapAmount = BigDecimal("300"), ), ), + additionalCashback = additional, + ) + + private fun additionalPromo() = CashbackPromotions.AdditionalCashback( + id = "promo-1", + name = "Groceries increase", + description = "+1% cashback for groceries stores", + isPermanent = true, + endDate = null, ) private fun docs() = listOf( diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackTiersConverterTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackTiersConverterTest.kt index 1f75fbc00d..b62946c256 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackTiersConverterTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackTiersConverterTest.kt @@ -79,13 +79,14 @@ internal class TangemPayCashbackTiersConverterTest { @Test fun `GIVEN no tiers WHEN convert THEN empty list`() { // Act - val result = converter.convert(CashbackPromotions(cardTiers = emptyList())) + val result = converter.convert(CashbackPromotions(cardTiers = emptyList(), additionalCashback = emptyList())) // Assert assertThat(result).isEmpty() } - private fun promotions(vararg tiers: CashbackPromotions.CardTier) = CashbackPromotions(cardTiers = tiers.toList()) + private fun promotions(vararg tiers: CashbackPromotions.CardTier) = + CashbackPromotions(cardTiers = tiers.toList(), additionalCashback = emptyList()) private fun tier( id: String = "basic", diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverterTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverterTest.kt index d73f1af43e..2249ea3fa5 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverterTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverterTest.kt @@ -23,8 +23,7 @@ internal class TangemPayCashbackUmConverterTest { private val defaultLocale = Locale.getDefault() - private val onCloseClick: () -> Unit = {} - private val converter = TangemPayCashbackUmConverter(onCloseClick = onCloseClick) + private val converter = TangemPayCashbackUmConverter() @BeforeEach fun setup() { @@ -51,7 +50,6 @@ internal class TangemPayCashbackUmConverterTest { subtitle = stringReference("Collected amount will be shown here"), isEmpty = true, banner = null, - onCloseClick = onCloseClick, ) assertThat(actual).isEqualTo(expected) } @@ -73,7 +71,6 @@ internal class TangemPayCashbackUmConverterTest { text = stringReference("Cashback $22.54 for June will be deposited till July 5"), type = TangemPayCashbackUM.Banner.Type.Info, ), - onCloseClick = onCloseClick, ) assertThat(actual).isEqualTo(expected) } @@ -97,7 +94,6 @@ internal class TangemPayCashbackUmConverterTest { ), type = TangemPayCashbackUM.Banner.Type.Error, ), - onCloseClick = onCloseClick, ) assertThat(actual).isEqualTo(expected) } @@ -122,7 +118,6 @@ internal class TangemPayCashbackUmConverterTest { text = stringReference("Cashback $22.54 for June will be deposited till August 2"), type = TangemPayCashbackUM.Banner.Type.Info, ), - onCloseClick = onCloseClick, ) assertThat(actual).isEqualTo(expected) }