Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-04 06:57:15 -07:00
parent 4390b59231
commit f839bc2178
20 changed files with 692 additions and 80 deletions

View file

@ -126,5 +126,9 @@
{
"name": "AND_15368_VISA_PAY_REDESIGN",
"version": "undefined"
},
{
"name": "AND_15364_VISA_PAY_CARD_CLOSE",
"version": "undefined"
}
]

View file

@ -1681,6 +1681,7 @@
<string name="tangem_pay_close_card_popup_primary_button_title">Close card</string>
<string name="tangem_pay_close_card_popup_secondary_button_title">Go back</string>
<string name="tangem_pay_close_card_popup_title">Close your card?</string>
<string name="tangem_pay_close_card_disabled_last_card">You cant close the last card</string>
<string name="tangem_pay_deposit">Deposit</string>
<string name="tangem_pay_dispute">Dispute</string>
<string name="tangem_pay_explore_transaction">Explore transaction</string>

View file

@ -56,7 +56,7 @@ fun TangemMessage(
subtitle = messageUM.subtitle,
messageEffect = messageUM.messageEffect,
isCentered = messageUM.isCentered,
content = {
trailingContent = {
if (messageUM.iconUM != null) {
TangemIcon(
tangemIconUM = messageUM.iconUM,
@ -107,7 +107,7 @@ fun TangemMessage(
title = config.title,
subtitle = config.subtitle,
modifier = modifier,
content = {
trailingContent = {
val iconTint = when (config.iconTint) {
NotificationConfig.IconTint.Unspecified -> null
NotificationConfig.IconTint.Accent -> TangemTheme.colors2.graphic.status.accent
@ -144,13 +144,14 @@ fun TangemMessage(
* Tangem message component that displays a message with optional title, subtitle, content, and buttons.
* [Message](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8455-81318&m=dev)
*
* @param modifier Modifier to be applied to the message component.
* @param title Optional title of the message.
* @param subtitle Optional subtitle of the message.
* @param messageEffect Effect to be applied to the message background.
* @param content Optional composable content to be displayed alongside the title and subtitle.
* @param buttons Optional composable buttons to be displayed below the message.
* @param isCentered Flag indicating whether the content should be centered horizontally.
* @param modifier Modifier to be applied to the message component.
* @param title Optional title of the message.
* @param subtitle Optional subtitle of the message.
* @param messageEffect Effect to be applied to the message background.
* @param leadingContent Optional composable content displayed before the title and subtitle.
* @param trailingContent Optional composable content displayed after the title and subtitle.
* @param buttons Optional composable buttons to be displayed below the message.
* @param isCentered Flag indicating whether the content should be centered horizontally.
*/
@Composable
fun TangemMessage(
@ -161,7 +162,8 @@ fun TangemMessage(
onCloseClick: (() -> Unit)? = null,
isCentered: Boolean = false,
contentColor: Color = TangemTheme.colors2.surface.level3,
content: (@Composable RowScope.() -> Unit)? = null,
leadingContent: (@Composable RowScope.() -> Unit)? = null,
trailingContent: (@Composable RowScope.() -> Unit)? = null,
buttons: (@Composable RowScope.() -> Unit)? = null,
) {
val alignment = if (isCentered) {
@ -190,7 +192,8 @@ fun TangemMessage(
title = title,
subtitle = subtitle,
alignment = alignment,
content = content,
leadingContent = leadingContent,
content = trailingContent,
isCentered = isCentered,
)
if (buttons != null) {
@ -224,6 +227,7 @@ private fun TangemMessageContent(
subtitle: TextReference? = null,
alignment: Alignment.Horizontal = Alignment.Start,
isCentered: Boolean = false,
leadingContent: (@Composable RowScope.() -> Unit)? = null,
content: (@Composable RowScope.() -> Unit)? = null,
) {
val textAlign = if (isCentered) {
@ -235,6 +239,7 @@ private fun TangemMessageContent(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
modifier = Modifier.padding(TangemTheme.dimens2.x1),
) {
leadingContent?.invoke(this)
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = alignment,
@ -423,7 +428,7 @@ private fun TangemMessage2_Preview() {
subtitle = stringReference("Subtext"),
messageEffect = TangemMessageEffect.Magic,
isCentered = false,
content = {
trailingContent = {
Box(
modifier = Modifier
.size(TangemTheme.dimens2.x7)

View file

@ -218,6 +218,21 @@ sealed class TangemPayAnalyticsEvents(
event = "Visa Replace Card Confirmed",
)
class CloseCardClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Close Card Clicked",
)
class CloseCardConfirmationPopupOpened : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Close Card Confirmation Popup Opened",
)
class CloseCardConfirmed : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Close Card Confirmed",
)
class LimitChangeClicked : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Daily Limit Change Clicked",

View file

@ -2,4 +2,5 @@ package com.tangem.features.tangempay
interface TangemPayFeatureToggles {
val isRedesignEnabled: Boolean
val isCloseCardEnabled: Boolean
}

View file

@ -9,4 +9,7 @@ internal class DefaultTangemPayFeatureToggles(
override val isRedesignEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15368_VISA_PAY_REDESIGN) &&
featureTogglesManager.isFeatureEnabled(FeatureToggles.APP_REDESIGN_ENABLED)
override val isCloseCardEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15364_VISA_PAY_CARD_CLOSE)
}

View file

@ -0,0 +1,35 @@
package com.tangem.features.tangempay.closure
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.wallet.UserWalletId
internal class TangemPayCloseCardComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: TangemPayCloseCardModel = getOrCreateModel(params = params)
override fun dismiss() = model.onDismiss()
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
TangemPayCloseCardContent(state = state)
}
data class Params(
val listener: CloseCardListener,
val userWalletId: UserWalletId,
val cardId: String,
)
}
internal interface CloseCardListener {
fun onDismissCloseCard()
}

View file

@ -0,0 +1,145 @@
package com.tangem.features.tangempay.closure
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.details.impl.R
@Composable
internal fun TangemPayCloseCardContent(state: TangemPayCloseCardUM) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = state.onDismissRequest,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors3.bg.secondary,
title = {
TangemTopBar(
type = TangemTopBarType.BottomSheet,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = state.onDismissRequest,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
},
content = {
Content(state)
},
)
}
@Composable
private fun Content(state: TangemPayCloseCardUM) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x4),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(TangemTheme.dimens2.x6))
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.warningSubtle),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_warning_20),
contentDescription = null,
tint = TangemTheme.colors3.icon.status.warning,
modifier = Modifier.size(28.dp),
)
}
Spacer(modifier = Modifier.height(TangemTheme.dimens2.x8))
Text(
text = stringResourceSafe(R.string.tangem_pay_close_card_popup_title),
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens2.x2))
Text(
text = stringResourceSafe(R.string.tangem_pay_close_card_popup_description),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens2.x12))
TangemButton(
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12,
text = resourceReference(R.string.tangem_pay_close_card_popup_secondary_button_title),
isEnabled = !state.isClosingInProgress,
onClick = state.onDismissRequest,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens2.x2))
TangemButton(
modifier = Modifier.fillMaxWidth(),
variant = TangemButton.Variant.Primary,
size = TangemButton.Size.X12,
text = resourceReference(R.string.tangem_pay_close_card_popup_primary_button_title),
isLoading = state.isClosingInProgress,
isEnabled = !state.isClosingInProgress,
onClick = state.onCloseClick,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens2.x4))
}
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreviewRedesign {
Content(
state = TangemPayCloseCardUM(
isClosingInProgress = true,
onCloseClick = {},
onDismissRequest = {},
),
)
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.tangempay.closure
import androidx.compose.runtime.Stable
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.pay.usecase.CloseTangemPayCardUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.details.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayCloseCardModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val closeTangemPayCardUseCase: CloseTangemPayCardUseCase,
private val uiMessageSender: UiMessageSender,
private val analytics: AnalyticsEventHandler,
) : Model() {
private val params = paramsContainer.require<TangemPayCloseCardComponent.Params>()
val state: StateFlow<TangemPayCloseCardUM>
field = MutableStateFlow(
TangemPayCloseCardUM(
isClosingInProgress = false,
onCloseClick = ::onConfirm,
onDismissRequest = ::onDismiss,
),
)
init {
analytics.send(TangemPayAnalyticsEvents.CloseCardConfirmationPopupOpened())
}
fun onDismiss() {
if (state.value.isClosingInProgress) return
params.listener.onDismissCloseCard()
}
private fun onConfirm() {
if (state.value.isClosingInProgress) return
analytics.send(TangemPayAnalyticsEvents.CloseCardConfirmed())
state.update { it.copy(isClosingInProgress = true) }
modelScope.launch {
closeTangemPayCardUseCase(
userWalletId = params.userWalletId,
cardId = params.cardId,
).onLeft {
state.update { uiState -> uiState.copy(isClosingInProgress = false) }
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong)))
params.listener.onDismissCloseCard()
}.onRight {
params.listener.onDismissCloseCard()
}
}
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.features.tangempay.closure
import androidx.compose.runtime.Immutable
@Immutable
internal data class TangemPayCloseCardUM(
val isClosingInProgress: Boolean,
val onCloseClick: () -> Unit,
val onDismissRequest: () -> Unit,
)

View file

@ -17,6 +17,7 @@ import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalVisaRedesignEnabled
import com.tangem.features.tangempay.closure.TangemPayCloseCardComponent
import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.entity.TangemPayCardNavigation
@ -90,6 +91,14 @@ internal class TangemPayCardPageScreenComponent(
cardId = params.initialStatus.firstCard().id,
),
)
is TangemPayCardNavigation.CloseCard -> TangemPayCloseCardComponent(
appComponentContext = context,
params = TangemPayCloseCardComponent.Params(
listener = model,
userWalletId = navigation.userWalletId,
cardId = navigation.cardId,
),
)
is TangemPayCardNavigation.AddFunds -> TangemPayAddFundsComponent(
appComponentContext = context,
params = TangemPayAddFundsComponent.Params(

View file

@ -6,6 +6,7 @@ import com.tangem.features.tangempay.model.TangemPayAddFundsModel
import com.tangem.features.tangempay.model.TangemPayAddToWalletModel
import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel
import com.tangem.features.tangempay.model.TangemPayCardPageModel
import com.tangem.features.tangempay.closure.TangemPayCloseCardModel
import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupModel
import com.tangem.features.tangempay.model.TangemPayChangePinModel
import com.tangem.features.tangempay.model.TangemPayDetailsModel
@ -79,6 +80,11 @@ internal interface TangemPayModelModule {
@ClassKey(TangemPayReissueCardModel::class)
fun bindTangemPayReissueCardModel(model: TangemPayReissueCardModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayCloseCardModel::class)
fun bindTangemPayCloseCardModel(model: TangemPayCloseCardModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayCardLimitSetupModel::class)

View file

@ -17,6 +17,12 @@ internal sealed class TangemPayCardNavigation {
@Serializable
data object ReissueCard : TangemPayCardNavigation()
@Serializable
data class CloseCard(
val userWalletId: UserWalletId,
val cardId: String,
) : TangemPayCardNavigation()
@Serializable
data class AddFunds(
val walletId: UserWalletId,

View file

@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.features.tangempay.details.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -16,7 +17,7 @@ internal data class TangemPayCardPageUM(
val onBackClick: () -> Unit,
val dailyLimitState: TangemPayDailyLimitBlockState,
val addToWalletBlockState: AddToWalletBlockState? = null,
val isReissueInProgress: Boolean = false,
val cardState: TangemPayCardState = TangemPayCardState.Active,
val menuItems: ImmutableList<TangemPayDropDownItemUM>,
) {
companion object {
@ -31,7 +32,7 @@ internal data class TangemPayCardPageUM(
TangemPayCardPageSetting(TextReference.Str("Freeze Card")) {},
TangemPayCardPageSetting(TextReference.Str("Reissue Card")) {},
),
isReissueInProgress: Boolean = false,
cardState: TangemPayCardState = TangemPayCardState.Active,
dailyLimitState: TangemPayDailyLimitBlockState = TangemPayDailyLimitBlockState.Content.stub(),
settingsV2: ImmutableList<TangemPayCardPageSettingV2> = TangemPayCardPageSettingV2.stubList(),
) = TangemPayCardPageUM(
@ -39,7 +40,7 @@ internal data class TangemPayCardPageUM(
settings = settings,
settingsV2 = settingsV2,
onBackClick = {},
isReissueInProgress = isReissueInProgress,
cardState = cardState,
dailyLimitState = dailyLimitState,
menuItems = persistentListOf(),
)

View file

@ -7,4 +7,6 @@ internal data class TangemPayDropDownItemUM(
val onClick: () -> Unit,
val title: TextReference,
val icon: TangemIconUM,
val subtitle: TextReference? = null,
val isEnabled: Boolean = true,
)

View file

@ -38,6 +38,7 @@ import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.AddFundsListener
import com.tangem.features.tangempay.closure.CloseCardListener
import com.tangem.features.tangempay.components.ReissueCardListener
import com.tangem.features.tangempay.components.TangemPayCardPageComponent
import com.tangem.features.tangempay.components.ViewPinListener
@ -72,7 +73,7 @@ internal class TangemPayCardPageModel @Inject constructor(
private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase,
private val cardDetailsEventListener: CardDetailsEventListener,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener {
) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener, CloseCardListener {
private val params: TangemPayCardPageComponent.Params = paramsContainer.require()
@ -94,7 +95,9 @@ internal class TangemPayCardPageModel @Inject constructor(
dailyLimitState = TangemPayDailyLimitBlockState.Loading,
settings = persistentListOf(),
settingsV2 = persistentListOf(),
menuItems = buildMenuItems(),
menuItems = buildMenuItems(
isLastCard = params.initialStatus.ifLoadedOrNull { it.cards.isLastCard() } ?: false,
),
),
)
@ -128,7 +131,8 @@ internal class TangemPayCardPageModel @Inject constructor(
dailyLimitState = dailyLimitState,
settings = buildSettings(card),
settingsV2 = buildSettingsV2(card),
isReissueInProgress = card.state == TangemPayCardState.Reissuing,
menuItems = buildMenuItems(isLastCard = status.cards.isLastCard()),
cardState = card.state,
)
}
} else {
@ -218,19 +222,45 @@ internal class TangemPayCardPageModel @Inject constructor(
)
}
private fun buildMenuItems(): ImmutableList<TangemPayDropDownItemUM> {
return persistentListOf(
TangemPayDropDownItemUM(
title = TextReference.Res(R.string.tangempay_card_details_reissue_card),
onClick = ::onClickReissueCard,
icon = TangemIconUM.Icon(
imageVector = Icons.ic_arrow_refresh_20,
tintReference = {
TangemTheme.colors3.icon.primary
},
private fun buildMenuItems(isLastCard: Boolean): ImmutableList<TangemPayDropDownItemUM> {
return buildList {
add(
TangemPayDropDownItemUM(
title = TextReference.Res(R.string.tangempay_card_details_reissue_card),
onClick = ::onClickReissueCard,
icon = TangemIconUM.Icon(
imageVector = Icons.ic_arrow_refresh_20,
tintReference = {
TangemTheme.colors3.icon.primary
},
),
),
),
)
)
if (tangemPayFeatureToggles.isCloseCardEnabled) {
add(
TangemPayDropDownItemUM(
title = TextReference.Res(R.string.tangem_pay_close_card_popup_primary_button_title),
onClick = ::onClickCloseCard,
icon = TangemIconUM.Icon(
iconRes = CoreUiR.drawable.ic_trash_24,
tintReference = {
if (isLastCard) {
TangemTheme.colors3.icon.tertiary
} else {
TangemTheme.colors3.icon.primary
}
},
),
subtitle = if (isLastCard) {
TextReference.Res(R.string.tangem_pay_close_card_disabled_last_card)
} else {
null
},
isEnabled = !isLastCard,
),
)
}
}.toImmutableList()
}
private fun onClickViewDetails() {
@ -278,6 +308,21 @@ internal class TangemPayCardPageModel @Inject constructor(
bottomSheetNavigation.dismiss()
}
override fun onDismissCloseCard() {
bottomSheetNavigation.dismiss()
}
private fun onClickCloseCard() {
analytics.send(TangemPayAnalyticsEvents.CloseCardClicked())
val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return
bottomSheetNavigation.activate(
TangemPayCardNavigation.CloseCard(
userWalletId = userWalletId,
cardId = card.id,
),
)
}
override fun onClickAddFunds() {
bottomSheetNavigation.dismiss()
modelScope.launch {
@ -403,4 +448,6 @@ internal class TangemPayCardPageModel @Inject constructor(
override fun onDismissViewPin() {
bottomSheetNavigation.dismiss()
}
}
}
private fun List<TangemPayCard>.isLastCard(): Boolean = count { it.state == TangemPayCardState.Active } <= 1

View file

@ -28,9 +28,11 @@ 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.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.*
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.details.impl.R
@ -82,7 +84,7 @@ internal fun TangemPayCardPageScreen(
state = cardDetailsState,
)
}
if (isRedesignEnabled && state.settingsV2.isNotEmpty()) {
if (isRedesignEnabled && state.settingsV2.isNotEmpty() && state.cardState == TangemPayCardState.Active) {
cardPageItem("Settings buttons") {
TangemPayCardPageSettingsButtonsBlock(
modifier = Modifier.fillMaxWidth(),
@ -90,28 +92,39 @@ internal fun TangemPayCardPageScreen(
)
}
}
if (state.isReissueInProgress) {
cardPageItem(key = "Reissue") {
TangemPayReplacingCardBlock()
}
} else {
if (state.addToWalletBlockState != null) {
cardPageItem(key = "GooglePay") {
TangemPayAddToWalletBlock(state = state.addToWalletBlockState)
}
}
cardPageItem(key = "Limit") {
TangemPayDailyLimitBlock(state = state.dailyLimitState)
}
if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) {
cardPageItem(key = "LimitError") {
TangemPayDailyLimitErrorBlock()
}
}
cardPageItem(key = "Settings") {
TangemPayCardPageSettingsBlock(settings = state.settings)
cardState(state)
}
}
}
private fun LazyListScope.cardState(state: TangemPayCardPageUM) {
when (state.cardState) {
TangemPayCardState.Active -> {
if (state.addToWalletBlockState != null) {
cardPageItem(key = "GooglePay") {
TangemPayAddToWalletBlock(state = state.addToWalletBlockState)
}
}
cardPageItem(key = "Limit") {
TangemPayDailyLimitBlock(state = state.dailyLimitState)
}
if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) {
cardPageItem(key = "LimitError") {
TangemPayDailyLimitErrorBlock()
}
}
cardPageItem(key = "Settings") {
TangemPayCardPageSettingsBlock(settings = state.settings)
}
}
TangemPayCardState.Reissuing -> cardPageItem(key = "Reissue") {
TangemPayReplacingCardBlock()
}
TangemPayCardState.Closing -> cardPageItem(key = "Closing") {
TangemPayReplacingCardBlock(
title = resourceReference(R.string.tangempay_card_page_closing_banner_title),
subtitle = resourceReference(R.string.tangempay_card_page_closing_banner_description),
)
}
}
}

View file

@ -1,35 +1,93 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalVisaRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.details.impl.R
@Composable
internal fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) {
internal fun TangemPayReplacingCardBlock(
modifier: Modifier = Modifier,
title: TextReference? = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
) {
if (LocalVisaRedesignEnabled.current) {
BlockV2(title = title, subtitle = subtitle, modifier = modifier)
} else {
BlockV1(title = title, subtitle = subtitle, modifier = modifier)
}
}
@Composable
private fun BlockV1(
modifier: Modifier = Modifier,
title: TextReference? = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
) {
Notification(
modifier = modifier,
config = NotificationConfig(
iconResId = R.drawable.ic_update_32,
iconTint = NotificationConfig.IconTint.Accent,
title = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
title = title,
subtitle = subtitle,
),
containerColor = TangemTheme.colors.background.primary,
)
}
@Composable
private fun BlockV2(
modifier: Modifier = Modifier,
title: TextReference? = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
) {
TangemMessage(
modifier = modifier,
title = title,
subtitle = subtitle,
leadingContent = {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
},
)
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreview {
TangemPayReplacingCardBlock()
Column {
TangemThemePreview {
TangemPayReplacingCardBlock()
}
SpacerH24()
CompositionLocalProvider(LocalVisaRedesignEnabled provides true) {
TangemThemePreviewRedesign {
TangemPayReplacingCardBlock()
}
}
}
}

View file

@ -1,18 +1,28 @@
package com.tangem.features.tangempay.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.ds.contextmenu.TangemContextMenu
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayDropDownItemUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.R as CoreUiR
@Composable
internal fun PayContextMenuBlock(
@ -28,30 +38,93 @@ internal fun PayContextMenuBlock(
modifier = modifier,
) {
items.fastForEach { item ->
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
modifier = Modifier
.clickableSingle(
onClick = {
item.onClick()
onMenuDismiss()
},
)
.padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4),
) {
TangemIcon(
modifier = Modifier.size(TangemTheme.dimens2.x5),
tangemIconUM = item.icon,
PayContextMenuItem(item = item, onMenuDismiss = onMenuDismiss)
}
}
}
@Composable
private fun PayContextMenuItem(item: TangemPayDropDownItemUM, onMenuDismiss: () -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.conditional(
condition = item.isEnabled,
modifier = {
clickableSingle(
onClick = {
item.onClick()
onMenuDismiss()
},
)
Text(
text = item.title.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
maxLines = 1,
)
}
},
)
.padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4),
) {
TangemIcon(
modifier = Modifier.size(TangemTheme.dimens2.x5),
tangemIconUM = item.icon,
)
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5)) {
Text(
text = item.title.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = if (item.isEnabled) {
TangemTheme.colors3.text.primary
} else {
TangemTheme.colors3.text.tertiary
},
maxLines = 1,
)
item.subtitle?.let { subtitle ->
Text(
text = subtitle.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
maxLines = 2,
)
}
}
}
}
}
// region Preview
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview() {
TangemThemePreviewRedesign {
Column {
persistentListOf(
previewReplaceItem(),
previewCloseItem(),
).fastForEach { item ->
PayContextMenuItem(item = item, onMenuDismiss = {})
}
}
}
}
private fun previewReplaceItem() = TangemPayDropDownItemUM(
title = TextReference.Res(R.string.tangempay_card_details_reissue_card),
onClick = {},
icon = TangemIconUM.Icon(
iconRes = CoreUiR.drawable.ic_refresh_24,
tintReference = { TangemTheme.colors3.icon.primary },
),
)
private fun previewCloseItem() = TangemPayDropDownItemUM(
title = TextReference.Res(R.string.tangem_pay_close_card_popup_primary_button_title),
onClick = {},
icon = TangemIconUM.Icon(
iconRes = CoreUiR.drawable.ic_trash_24,
tintReference = { TangemTheme.colors3.icon.tertiary },
),
subtitle = TextReference.Res(R.string.tangem_pay_close_card_disabled_last_card),
isEnabled = false,
)
// endregion

View file

@ -0,0 +1,110 @@
package com.tangem.features.tangempay.closure
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.CloseTangemPayCardUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
internal class TangemPayCloseCardModelTest {
private val cardId = "test_card_id"
private val userWalletId = UserWalletId("123")
private val listener: CloseCardListener = mockk(relaxed = true)
private val closeCardUseCase: CloseTangemPayCardUseCase = mockk()
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
private val params = TangemPayCloseCardComponent.Params(
listener = listener,
userWalletId = userWalletId,
cardId = cardId,
)
private fun createModel() = TangemPayCloseCardModel(
paramsContainer = MutableParamsContainer(params),
dispatchers = TestingCoroutineDispatcherProvider(),
closeTangemPayCardUseCase = closeCardUseCase,
uiMessageSender = uiMessageSender,
analytics = analytics,
)
@Test
fun `GIVEN model WHEN created THEN CloseCardConfirmationPopupOpened is sent`() {
createModel()
verify(exactly = 1) {
analytics.send(TangemPayAnalyticsEvents.CloseCardConfirmationPopupOpened())
}
}
@Test
fun `GIVEN not closing WHEN onDismiss THEN listener notified and use case not called`() {
val model = createModel()
model.state.value.onDismissRequest()
verify(exactly = 1) { listener.onDismissCloseCard() }
coVerify(exactly = 0) { closeCardUseCase(any(), any()) }
}
@Nested
inner class OnConfirm {
@Test
fun `GIVEN close succeeds WHEN onCloseClick THEN use case invoked and dialog dismissed`() {
coEvery { closeCardUseCase(userWalletId, cardId) } returns Unit.right()
val model = createModel()
model.state.value.onCloseClick()
coVerify(exactly = 1) { closeCardUseCase(userWalletId, cardId) }
verify(exactly = 1) { listener.onDismissCloseCard() }
}
@Test
fun `GIVEN close succeeds WHEN onCloseClick THEN CloseCardConfirmed is sent`() {
coEvery { closeCardUseCase(userWalletId, cardId) } returns Unit.right()
val model = createModel()
model.state.value.onCloseClick()
verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.CloseCardConfirmed()) }
}
@Test
fun `GIVEN close fails WHEN onCloseClick THEN snackbar shown and dialog dismissed`() {
coEvery { closeCardUseCase(userWalletId, cardId) } returns VisaApiError.Unspecified.left()
val model = createModel()
model.state.value.onCloseClick()
verify(exactly = 1) { uiMessageSender.send(any<SnackbarMessage>()) }
verify(exactly = 1) { listener.onDismissCloseCard() }
}
@Test
fun `GIVEN close fails WHEN onCloseClick THEN progress is reset`() {
coEvery { closeCardUseCase(userWalletId, cardId) } returns VisaApiError.Unspecified.left()
val model = createModel()
model.state.value.onCloseClick()
assertThat(model.state.value.isClosingInProgress).isFalse()
}
}
}