From 8b49dc2f7f74733d4c1534100993e3aeb9956994 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 15:07:42 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../core/ui/utils/DateTimeFormatters.kt | 17 ++ .../domain/pay/model/TangemPayCashback.kt | 18 ++ .../api/TangemPayCashbackComponent.kt | 16 ++ .../impl/DefaultTangemPayCashbackComponent.kt | 36 +++ .../model/TangemPayCashbackDateFormatter.kt | 21 ++ .../impl/model/TangemPayCashbackModel.kt | 41 ++++ .../model/TangemPayCashbackUmConverter.kt | 54 +++++ .../impl/ui/TangemPayCashbackScreen.kt | 217 ++++++++++++++++++ .../impl/ui/state/TangemPayCashbackUM.kt | 22 ++ ...faultTangemPayDetailsContainerComponent.kt | 8 + .../di/TangemPayDetailsFeatureModule.kt | 8 + .../tangempay/di/TangemPayModelModule.kt | 6 + .../tangempay/model/TangemPayDetailsModel.kt | 4 + .../TangemPayAccountDetailsInnerRoute.kt | 3 + .../tangempay/utils/TangemPayDetailIntents.kt | 1 + .../TangemPayCashbackDateFormatterTest.kt | 70 ++++++ .../model/TangemPayCashbackUmConverterTest.kt | 152 ++++++++++++ 17 files changed, 694 insertions(+) create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCashback.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/api/TangemPayCashbackComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/DefaultTangemPayCashbackComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatter.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverter.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackScreen.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackUM.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatterTest.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverterTest.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt index 5a45b12535..e7630f1f28 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt @@ -108,6 +108,23 @@ object DateTimeFormatters { getBestFormatterBySkeleton("d MMMM") } + /** + * Example: "June" + */ + val dateMMMM: DateTimeFormatter by lazy { + getBestFormatterBySkeleton("MMMM") + } + + /** + * Example: "June 1" + */ + val dateMMMMd: DateTimeFormatter by lazy { + DateTimeFormatterBuilder() + .appendPattern("MMMM d") + .toFormatter() + .withLocale(Locale.getDefault()) + } + /** * Example: "31.06.2020 12:00", "06/31/2020 12:00", "06/31/2020 12:00 PM" */ diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCashback.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCashback.kt new file mode 100644 index 0000000000..aa1545971b --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCashback.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.pay.model + +import org.joda.time.DateTime +import java.math.BigDecimal + +data class TangemPayCashback( + val confirmedAmount: BigDecimal, + val currency: String, + val period: Period, +) { + data class Period( + val year: Int, + /** 1-based calendar month (6 = June). */ + val month: Int, + val payoutStart: DateTime, + val payoutEnd: DateTime, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/api/TangemPayCashbackComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/api/TangemPayCashbackComponent.kt new file mode 100644 index 0000000000..78d017faad --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/api/TangemPayCashbackComponent.kt @@ -0,0 +1,16 @@ +package com.tangem.features.tangempay.cashback.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Public contract of the Cashback screen. Kept in an `api` sub-package (separate from `impl`) so it + * can be lifted into a dedicated module without refactoring. + */ +internal interface TangemPayCashbackComponent : ComposableContentComponent { + + data class Params(val userWalletId: UserWalletId) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/DefaultTangemPayCashbackComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/DefaultTangemPayCashbackComponent.kt new file mode 100644 index 0000000000..526e6b0a7a --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/DefaultTangemPayCashbackComponent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.tangempay.cashback.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent +import com.tangem.features.tangempay.cashback.impl.model.TangemPayCashbackModel +import com.tangem.features.tangempay.cashback.impl.ui.TangemPayCashbackScreen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultTangemPayCashbackComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: TangemPayCashbackComponent.Params, +) : TangemPayCashbackComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayCashbackModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + TangemPayCashbackScreen(state = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : TangemPayCashbackComponent.Factory { + override fun create( + context: AppComponentContext, + params: TangemPayCashbackComponent.Params, + ): DefaultTangemPayCashbackComponent + } +} \ 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 new file mode 100644 index 0000000000..0fcd238c87 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatter.kt @@ -0,0 +1,21 @@ +package com.tangem.features.tangempay.cashback.impl.model + +import com.tangem.core.ui.utils.DateTimeFormatters +import org.joda.time.DateTime + +internal class TangemPayCashbackDateFormatter { + + fun formatMonth(year: Int, month: Int): String = + DateTimeFormatters.formatDate(DateTime(year, month, 1, 0, 0), DateTimeFormatters.dateMMMM) + + fun formatMonthDay(date: DateTime): String = DateTimeFormatters.formatDate(date, DateTimeFormatters.dateMMMMd) + + fun formatWindow(start: DateTime, end: DateTime): String { + val isSameMonth = start.year == end.year && start.monthOfYear == end.monthOfYear + return if (isSameMonth) { + "${formatMonthDay(start)}–${end.dayOfMonth}" + } else { + "${formatMonthDay(start)} – ${formatMonthDay(end)}" + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..5804d12434 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackModel.kt @@ -0,0 +1,41 @@ +package com.tangem.features.tangempay.cashback.impl.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.pay.model.TangemPayCashback +import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.joda.time.DateTime +import java.math.BigDecimal +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayCashbackModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, +) : Model() { + + private val cashbackConverter = TangemPayCashbackUmConverter(onCloseClick = router::pop) + + val uiState: StateFlow + field = MutableStateFlow(cashbackConverter.convert(STUB_CASHBACK)) + + private companion object { + // TODO([REDACTED_TASK_KEY]): replace stub with repository load + val STUB_CASHBACK = TangemPayCashback( + confirmedAmount = BigDecimal("22.54"), + currency = "USD", + period = TangemPayCashback.Period( + year = 2026, + month = 6, + payoutStart = DateTime.parse("2026-07-01"), + payoutEnd = DateTime.parse("2026-07-05"), + ), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..35bbfafb62 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverter.kt @@ -0,0 +1,54 @@ +package com.tangem.features.tangempay.cashback.impl.model + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.format.bigdecimal.optionalDecimals +import com.tangem.domain.pay.model.TangemPayCashback +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 { + + // TODO([REDACTED_TASK_KEY]): move hardcoded strings to string resources + override fun convert(value: TangemPayCashback?): TangemPayCashbackUM { + if (value == null || value.confirmedAmount.signum() == 0) { + return TangemPayCashbackUM( + title = stringReference("Start spending and earn cashback"), + subtitle = stringReference("Collected amount will be shown here"), + isEmpty = true, + banner = null, + onCloseClick = onCloseClick, + ) + } + val currency = getJavaCurrencyByCode(value.currency) + val earned = value.confirmedAmount.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() } + val month = dateFormatter.formatMonth(value.period.year, value.period.month) + val payoutWindow = dateFormatter.formatWindow(value.period.payoutStart, value.period.payoutEnd) + val payoutEnd = dateFormatter.formatMonthDay(value.period.payoutEnd) + val banner = if (value.confirmedAmount.signum() < 0) { + TangemPayCashbackUM.Banner( + text = stringReference( + "We received a refund for a purchase for which cashback had previously been awarded", + ), + type = TangemPayCashbackUM.Banner.Type.Error, + ) + } else { + TangemPayCashbackUM.Banner( + text = stringReference("Cashback $earned for $month will be deposited till $payoutEnd"), + type = TangemPayCashbackUM.Banner.Type.Info, + ) + } + return TangemPayCashbackUM( + title = stringReference("$earned earned in $month"), + 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/TangemPayCashbackScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackScreen.kt new file mode 100644 index 0000000000..65635bc62a --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/TangemPayCashbackScreen.kt @@ -0,0 +1,217 @@ +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +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.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 com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +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.TangemPayCashbackUM +import com.tangem.features.tangempay.details.impl.R +import com.tangem.core.ui.R as CoreUiR + +private const val GLOW_RADIUS_FACTOR = 0.585f +private const val GLOW_BLUE_ALPHA = 0.20f +private const val GLOW_WARM_ALPHA = 0.15f + +@Composable +internal fun TangemPayCashbackScreen(state: TangemPayCashbackUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors3.bg.primary), + ) { + if (state.isEmpty) { + EmptyStateGlow(modifier = Modifier.fillMaxSize()) + } + Column(modifier = Modifier.fillMaxSize()) { + TangemTopBar( + modifier = Modifier.statusBarsPadding(), + // TODO([REDACTED_TASK_KEY]): move to string resources + title = stringReference("Cashback"), + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24), + onClick = state.onCloseClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) + HeroBlock(state = state) + state.banner?.let { banner -> + CashbackBanner( + banner = banner, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + } +} + +@Suppress("MagicNumber") +@Composable +private fun EmptyStateGlow(modifier: Modifier = Modifier) { + val isDark = LocalIsInDarkTheme.current + val warm = if (isDark) Color(0xFF7A4A25) else Color(0xFFEA8C44) + val blue = if (isDark) Color(0xFF0090F9) else Color(0xFF0092FC) + Box( + modifier = modifier + .blur(56.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded) + .drawBehind { + val radius = size.width * GLOW_RADIUS_FACTOR + val center = Offset(x = size.width / 2f, y = 0f) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + Color.Transparent, + warm.copy(alpha = GLOW_WARM_ALPHA), + blue.copy(alpha = GLOW_BLUE_ALPHA), + ), + center = center, + radius = radius, + ), + radius = radius, + center = center, + ) + }, + ) +} + +@Composable +private fun HeroBlock(state: TangemPayCashbackUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = state.title.resolveReference(), + style = TangemTheme.typography3.heading.medium, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = state.subtitle.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun CashbackBanner(banner: TangemPayCashbackUM.Banner, modifier: Modifier = Modifier) { + val contentColor = when (banner.type) { + TangemPayCashbackUM.Banner.Type.Info -> TangemTheme.colors3.text.status.info + TangemPayCashbackUM.Banner.Type.Error -> TangemTheme.colors3.text.status.error + } + val backgroundColor = when (banner.type) { + TangemPayCashbackUM.Banner.Type.Info -> TangemTheme.colors3.bg.status.infoSubtle + TangemPayCashbackUM.Banner.Type.Error -> TangemTheme.colors3.bg.status.errorSubtle + } + Row( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(backgroundColor) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(CoreUiR.drawable.ic_information_24), + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(20.dp), + ) + Text( + text = banner.text.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = contentColor, + ) + } +} + +@Preview(device = Devices.PIXEL_7_PRO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) +@Composable +private fun TangemPayCashbackScreenPreview( + @PreviewParameter(TangemPayCashbackUMProvider::class) state: TangemPayCashbackUM, +) { + TangemThemePreviewRedesign { + TangemPayCashbackScreen(state = state) + } +} + +private class TangemPayCashbackUMProvider : CollectionPreviewParameterProvider( + collection = listOf( + TangemPayCashbackUM( + title = stringReference("$22.54 earned in June"), + subtitle = stringReference("Will be deposited on July 1–5"), + isEmpty = false, + banner = TangemPayCashbackUM.Banner( + text = stringReference("Cashback $22.54 for June will be deposited till July 5"), + type = TangemPayCashbackUM.Banner.Type.Info, + ), + onCloseClick = {}, + ), + 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 = {}, + ), + TangemPayCashbackUM( + title = stringReference("Start spending and earn cashback"), + subtitle = stringReference("Collected amount will be shown here"), + isEmpty = true, + banner = null, + onCloseClick = {}, + ), + ), +) \ 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 new file mode 100644 index 0000000000..3047882a69 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/cashback/impl/ui/state/TangemPayCashbackUM.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay.cashback.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal data class TangemPayCashbackUM( + val title: TextReference, + val subtitle: TextReference, + val isEmpty: Boolean, + val banner: Banner?, + val onCloseClick: () -> Unit, +) { + + @Immutable + data class Banner( + val text: TextReference, + val type: Type, + ) { + enum class Type { Info, Error } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 3bb93950a6..cf7a85c475 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -16,6 +16,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.promobanners.api.PromoBannersBlockComponent +import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.tiers.current.TangemPayCurrentPlanComponent import com.tangem.features.tangempay.tiers.select.TangemPaySelectPlanComponent @@ -36,6 +37,7 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru private val expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val virtualAccountAddFundsComponentFactory: VirtualAccountAddFundsBottomSheetComponent.Factory, + private val cashbackComponentFactory: TangemPayCashbackComponent.Factory, ) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { private val stackNavigation = StackNavigation() @@ -107,6 +109,12 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru TangemPayVirtualAccountDepositSuccessComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), ) + TangemPayAccountDetailsInnerRoute.Cashback -> cashbackComponentFactory.create( + context = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayCashbackComponent.Params( + userWalletId = params.initialStatus.userWalletId, + ), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt index fd27c59344..fa3ce504f7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt @@ -1,5 +1,7 @@ package com.tangem.features.tangempay.di +import com.tangem.features.tangempay.cashback.api.TangemPayCashbackComponent +import com.tangem.features.tangempay.cashback.impl.DefaultTangemPayCashbackComponent import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.components.TangemPayTransactionBottomSheetComponent @@ -31,4 +33,10 @@ internal interface TangemPayDetailsFeatureModule { fun bindTangemPayTransactionBottomSheetComponentFactory( factory: TangemPayTxHistoryDetailsComponent.Factory, ): TangemPayTransactionBottomSheetComponent.Factory + + @Binds + @Singleton + fun bindTangemPayCashbackComponentFactory( + factory: DefaultTangemPayCashbackComponent.Factory, + ): TangemPayCashbackComponent.Factory } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index e7216b1b93..9868806b5a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -2,6 +2,7 @@ package com.tangem.features.tangempay.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model +import com.tangem.features.tangempay.cashback.impl.model.TangemPayCashbackModel import com.tangem.features.tangempay.closure.TangemPayCloseCardModel import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupModel import com.tangem.features.tangempay.model.* @@ -96,4 +97,9 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPaySelectPlanModel::class) fun bindTangemPaySelectPlanModel(model: TangemPaySelectPlanModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayCashbackModel::class) + fun bindTangemPayCashbackModel(model: TangemPayCashbackModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 0f1d44f855..36dc36261b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -427,6 +427,10 @@ internal class TangemPayDetailsModel @Inject constructor( router.push(TangemPayAccountDetailsInnerRoute.CurrentPlan(tariffPlan)) } + override fun onClickCashback() { + router.push(TangemPayAccountDetailsInnerRoute.Cashback) + } + override fun onCardClick(cardId: String) { analytics.send(TangemPayAnalyticsEvents.CardIconClicked()) router.push(TangemPayAccountDetailsInnerRoute.CardDetails(cardId = cardId)) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt index 540efd54e8..bdcc97679b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt @@ -28,4 +28,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route { @Serializable data object VirtualAccountDepositSuccess : TangemPayAccountDetailsInnerRoute() + + @Serializable + data object Cashback : TangemPayAccountDetailsInnerRoute() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt index dd7d0208ec..9dc97785a8 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt @@ -11,6 +11,7 @@ internal interface TangemPayDetailIntents { fun onClickWithdraw() fun onClickTermsAndLimits() fun onClickVisaBenefits() + fun onClickCashback() fun onClickCurrentPlan(tariffPlan: TangemPayCustomerTariffPlan) fun onCancelPlusTransition(orderId: String) fun onCardClick(cardId: String) 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 new file mode 100644 index 0000000000..1fed0398d3 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackDateFormatterTest.kt @@ -0,0 +1,70 @@ +package com.tangem.features.tangempay.cashback.impl.model + +import android.text.format.DateFormat +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.joda.time.DateTime +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.util.Locale + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayCashbackDateFormatterTest { + + private val defaultLocale = Locale.getDefault() + + private val formatter = TangemPayCashbackDateFormatter() + + @BeforeEach + fun setup() { + Locale.setDefault(Locale.US) + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + } + + @AfterEach + fun tearDown() { + unmockkStatic(DateFormat::class) + Locale.setDefault(defaultLocale) + } + + @Test + fun `GIVEN year and month WHEN formatMonth THEN full month name`() { + // Act + val actual = formatter.formatMonth(year = 2026, month = 6) + + // Assert + assertThat(actual).isEqualTo("June") + } + + @Test + fun `GIVEN date WHEN formatMonthDay THEN month name then day`() { + // Act + val actual = formatter.formatMonthDay(DateTime.parse("2026-07-05")) + + // Assert + assertThat(actual).isEqualTo("July 5") + } + + @Test + fun `GIVEN window within one month WHEN formatWindow THEN single month with day range`() { + // Act + val actual = formatter.formatWindow(DateTime.parse("2026-07-01"), DateTime.parse("2026-07-05")) + + // Assert + assertThat(actual).isEqualTo("July 1–5") + } + + @Test + fun `GIVEN window spanning two months WHEN formatWindow THEN month name on both sides`() { + // Act + val actual = formatter.formatWindow(DateTime.parse("2026-07-30"), DateTime.parse("2026-08-02")) + + // Assert + assertThat(actual).isEqualTo("July 30 – August 2") + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..309467f17c --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/cashback/impl/model/TangemPayCashbackUmConverterTest.kt @@ -0,0 +1,152 @@ +package com.tangem.features.tangempay.cashback.impl.model + +import android.text.format.DateFormat +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.pay.model.TangemPayCashback +import com.tangem.features.tangempay.cashback.impl.ui.state.TangemPayCashbackUM +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.joda.time.DateTime +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal +import java.util.Locale + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayCashbackUmConverterTest { + + private val defaultLocale = Locale.getDefault() + + private val onCloseClick: () -> Unit = {} + private val converter = TangemPayCashbackUmConverter(onCloseClick = onCloseClick) + + @BeforeEach + fun setup() { + Locale.setDefault(Locale.US) + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + } + + @AfterEach + fun tearDown() { + unmockkStatic(DateFormat::class) + Locale.setDefault(defaultLocale) + } + + @ParameterizedTest + @MethodSource("emptyStateCashback") + fun `GIVEN null or zero amount WHEN convert THEN empty state without banner`(cashback: TangemPayCashback?) { + // Act + val actual = converter.convert(cashback) + + // Assert + val expected = TangemPayCashbackUM( + title = stringReference("Start spending and earn cashback"), + subtitle = stringReference("Collected amount will be shown here"), + isEmpty = true, + banner = null, + onCloseClick = onCloseClick, + ) + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `GIVEN positive amount WHEN convert THEN earned title and info deposit banner`() { + // Arrange + val cashback = createCashback(confirmedAmount = BigDecimal("22.54")) + + // Act + val actual = converter.convert(cashback) + + // Assert + val expected = TangemPayCashbackUM( + title = stringReference("$22.54 earned in June"), + subtitle = stringReference("Will be deposited on July 1–5"), + isEmpty = false, + banner = TangemPayCashbackUM.Banner( + text = stringReference("Cashback $22.54 for June will be deposited till July 5"), + type = TangemPayCashbackUM.Banner.Type.Info, + ), + onCloseClick = onCloseClick, + ) + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `GIVEN negative amount WHEN convert THEN earned title and refund error banner`() { + // Arrange + val cashback = createCashback(confirmedAmount = BigDecimal("-22.54")) + + // Act + val actual = converter.convert(cashback) + + // Assert + val expected = 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 = onCloseClick, + ) + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `GIVEN payout window spanning two months WHEN convert THEN subtitle shows month on both sides`() { + // Arrange + val cashback = createCashback( + payoutStart = DateTime.parse("2026-07-30"), + payoutEnd = DateTime.parse("2026-08-02"), + ) + + // Act + val actual = converter.convert(cashback) + + // Assert + val expected = TangemPayCashbackUM( + title = stringReference("$22.54 earned in June"), + subtitle = stringReference("Will be deposited on July 30 – August 2"), + isEmpty = false, + banner = TangemPayCashbackUM.Banner( + text = stringReference("Cashback $22.54 for June will be deposited till August 2"), + type = TangemPayCashbackUM.Banner.Type.Info, + ), + onCloseClick = onCloseClick, + ) + assertThat(actual).isEqualTo(expected) + } + + private fun emptyStateCashback(): List = listOf( + null, + createCashback(confirmedAmount = BigDecimal.ZERO), + ) + + private fun createCashback( + confirmedAmount: BigDecimal = BigDecimal("22.54"), + currency: String = "USD", + year: Int = 2026, + month: Int = 6, + payoutStart: DateTime = DateTime.parse("2026-07-01"), + payoutEnd: DateTime = DateTime.parse("2026-07-05"), + ): TangemPayCashback = TangemPayCashback( + confirmedAmount = confirmedAmount, + currency = currency, + period = TangemPayCashback.Period( + year = year, + month = month, + payoutStart = payoutStart, + payoutEnd = payoutEnd, + ), + ) +} \ No newline at end of file