Updated on 2026-08-14
This commit is contained in:
parent
b4d11538ea
commit
454c08b894
21 changed files with 982 additions and 112 deletions
|
|
@ -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?,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<CashbackPromotionsResponse, CashbackPromotions> {
|
||||
|
|
@ -10,6 +11,7 @@ internal object CashbackPromotionsConverter : Converter<CashbackPromotionsRespon
|
|||
override fun convert(value: CashbackPromotionsResponse): CashbackPromotions {
|
||||
return CashbackPromotions(
|
||||
cardTiers = value.cashbackOnCards?.tiers.orEmpty().map(::convertTier),
|
||||
additionalCashback = value.additionalCashback.orEmpty().map(::convertAdditional),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -22,4 +24,17 @@ internal object CashbackPromotionsConverter : Converter<CashbackPromotionsRespon
|
|||
monthlyCapAmount = tier.tierMonthlyCapAmount,
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertAdditional(
|
||||
promo: CashbackPromotionsResponse.AdditionalCashback,
|
||||
): CashbackPromotions.AdditionalCashback {
|
||||
val endDate = promo.endDate?.let { runCatching { DateTime.parse(it) }.getOrNull() }
|
||||
return CashbackPromotions.AdditionalCashback(
|
||||
id = promo.id.orEmpty(),
|
||||
name = promo.name.orEmpty(),
|
||||
description = promo.description.orEmpty(),
|
||||
isPermanent = promo.isPermanent ?: (endDate == null),
|
||||
endDate = endDate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +99,29 @@ internal class MockAwareCashbackRepository @Inject constructor(
|
|||
monthlyCapAmount = BigDecimal("300"),
|
||||
),
|
||||
),
|
||||
additionalCashback = listOf(
|
||||
CashbackPromotions.AdditionalCashback(
|
||||
id = "promo-permanent",
|
||||
name = "Groceries increase",
|
||||
description = "+1% cashback for groceries stores",
|
||||
isPermanent = true,
|
||||
endDate = null,
|
||||
),
|
||||
CashbackPromotions.AdditionalCashback(
|
||||
id = "promo-groceries-2026",
|
||||
name = "Groceries increase",
|
||||
description = "+1% cashback for groceries stores. Max \$10/month",
|
||||
isPermanent = false,
|
||||
endDate = DateTime.parse("2026-09-26"),
|
||||
),
|
||||
CashbackPromotions.AdditionalCashback(
|
||||
id = "promo-cashback-2026",
|
||||
name = "Cashback increase",
|
||||
description = "+2% cashback for groceries stores. Max \$10/month",
|
||||
isPermanent = false,
|
||||
endDate = DateTime.parse("2026-09-26"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val MOCK_DOCS = listOf(
|
||||
|
|
|
|||
|
|
@ -7,16 +7,25 @@ import com.tangem.datasource.api.common.config.ApiConfig
|
|||
import com.tangem.datasource.api.common.config.ApiEnvironment
|
||||
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
|
||||
import com.tangem.domain.models.account.BankCredentials
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.pay.TangemPayCard
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import com.tangem.domain.models.pay.TangemPayEligibilityType
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** In MOCK env skips local-storage / signing enrollment; server calls go to WireMock. */
|
||||
/**
|
||||
* In MOCK env returns canned onboarding data so the Payment Account shows up as fully loaded on the main
|
||||
* screen (the entry point to the TangemPay details & cashback screens), and skips local-storage / signing
|
||||
* enrollment; remaining server calls go to WireMock.
|
||||
*/
|
||||
@Singleton
|
||||
internal class MockAwareOnboardingRepository @Inject constructor(
|
||||
private val real: DefaultOnboardingRepository,
|
||||
|
|
@ -46,8 +55,10 @@ internal class MockAwareOnboardingRepository @Inject constructor(
|
|||
real.produceInitialData(userWalletId)
|
||||
}
|
||||
|
||||
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> =
|
||||
real.getCustomerInfo(userWalletId)
|
||||
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> {
|
||||
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<VisaApiError, Boolean> =
|
||||
real.hasTangemPayInWallet(userWalletId)
|
||||
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
|
||||
if (isMockMode) return true.right()
|
||||
return real.hasTangemPayInWallet(userWalletId)
|
||||
}
|
||||
|
||||
override suspend fun checkCustomerEligibility(): List<TangemPayEligibilityType> =
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<CardTier>,
|
||||
val additionalCashback: List<AdditionalCashback>,
|
||||
) {
|
||||
|
||||
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?,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<List<CashbackPromotions.AdditionalCashback>, TangemPayAdditionalCashbackUM> {
|
||||
|
||||
// TODO([REDACTED_TASK_KEY]): move hardcoded strings to string resources
|
||||
override fun convert(value: List<CashbackPromotions.AdditionalCashback>): 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)}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<TangemPayCashbackNavigation> = 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<TangemPayCashbackScreenUM>
|
||||
field = MutableStateFlow(
|
||||
TangemPayCashbackScreenUM(
|
||||
cashback = cashbackConverter.convert(value = null),
|
||||
infoTiles = null,
|
||||
histogram = null,
|
||||
),
|
||||
field = MutableStateFlow<TangemPayCashbackScreenUM>(
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -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<TangemPayCashback?, TangemPayCashbackUM> {
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TangemPayAdditionalCashbackUM>(
|
||||
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")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -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<TangemPayCashbackScreenUM>(
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -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<Dp> = 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Item>,
|
||||
) {
|
||||
|
||||
/**
|
||||
* @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 <date>" pill with a clock icon. */
|
||||
data class Until(val text: TextReference) : Badge
|
||||
}
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ internal data class TangemPayCashbackUM(
|
|||
val subtitle: TextReference,
|
||||
val isEmpty: Boolean,
|
||||
val banner: Banner?,
|
||||
val onCloseClick: () -> Unit,
|
||||
) {
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CashbackPromotions.AdditionalCashback> = 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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue