diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 95f1a0e965..e8883a97a4 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -127,6 +127,10 @@ "name": "AND_15368_VISA_PAY_REDESIGN", "version": "undefined" }, + { + "name": "AND_15364_VISA_PAY_CARD_CLOSE", + "version": "undefined" + }, { "name": "TWI_83_ADDRESS_BOOK_ENABLED", "version": "undefined" diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 18fff34c69..87d1ef2075 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1681,6 +1681,7 @@ Close card Go back Close your card? + You can’t close the last card Deposit Dispute Explore transaction diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt index 282be7cef8..ecda9ea392 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt @@ -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) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index 18dadd605d..a955c92974 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -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", diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt index c2f6f12c37..aa6c289349 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt @@ -2,4 +2,5 @@ package com.tangem.features.tangempay interface TangemPayFeatureToggles { val isRedesignEnabled: Boolean + val isCloseCardEnabled: Boolean } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt index 0ea33aafcd..aa2166183f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt @@ -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) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardComponent.kt new file mode 100644 index 0000000000..13717caa01 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardComponent.kt @@ -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() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardContent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardContent.kt new file mode 100644 index 0000000000..19032854f9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardContent.kt @@ -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( + 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 = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardModel.kt new file mode 100644 index 0000000000..5ca6139ff4 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardModel.kt @@ -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() + + val state: StateFlow + 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() + } + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardUM.kt new file mode 100644 index 0000000000..aa06a1cc86 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardUM.kt @@ -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, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index 7e5dc2eada..8f34c3f102 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -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( 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 1766696a25..165d7cb95a 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 @@ -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) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt index f972f46a39..eef562c6c5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt @@ -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, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt index cb24f4b42d..406e3c16e6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -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, ) { 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.stubList(), ) = TangemPayCardPageUM( @@ -39,7 +40,7 @@ internal data class TangemPayCardPageUM( settings = settings, settingsV2 = settingsV2, onBackClick = {}, - isReissueInProgress = isReissueInProgress, + cardState = cardState, dailyLimitState = dailyLimitState, menuItems = persistentListOf(), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt index 71d30e3f2e..904b272672 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDropDownItemUM.kt @@ -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, ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 97dcffc0fd..cfdd4ebb6f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -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 { - 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 { + 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() } -} \ No newline at end of file +} + +private fun List.isLastCard(): Boolean = count { it.state == TangemPayCardState.Active } <= 1 \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 044a3ea8c2..6bb50a2d87 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -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), + ) } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt index ac1af73851..88a94ec847 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt @@ -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() + } + } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt index 88dd7ca342..7e914f42d5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/PayContextMenuBlock.kt @@ -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, + ) } } } -} \ No newline at end of file +} + +// 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 \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardModelTest.kt new file mode 100644 index 0000000000..03689d3e06 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/closure/TangemPayCloseCardModelTest.kt @@ -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()) } + 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() + } + } +} \ No newline at end of file