From ebf28a282bad10526601ff94ed883d54c9208751 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 22:39:23 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/datasource/api/pay/TangemPayApi.kt | 4 +- .../DefaultTangemPayCardDetailsRepository.kt | 6 +- ...MockAwareTangemPayCardDetailsRepository.kt | 3 +- .../TangemPayCardDetailsRepository.kt | 5 +- .../tangempay/TangemPayAnalyticsEvents.kt | 8 +- ...faultTangemPayDetailsContainerComponent.kt | 7 +- .../TangemPayAddToWalletComponent.kt | 41 ++- .../components/TangemPayCardPageComponent.kt | 12 +- .../TangemPayCardPageScreenComponent.kt | 24 +- .../components/TangemPayCardScopedParams.kt | 12 + .../components/TangemPayDetailsComponent.kt | 10 + .../TangemPayEditDisplayNameComponent.kt | 35 +-- .../TangemPayIssueAdditionalCardComponent.kt | 45 +++ ...faultTangemPayCardDetailsBlockComponent.kt | 28 -- ...eviewTangemPayCardDetailsBlockComponent.kt | 20 -- .../TangemPayCardDetailsBlockComponent.kt | 24 -- .../tangempay/di/TangemPayModelModule.kt | 22 +- .../TangemPayCardDetailsBlockStateFactory.kt | 1 - .../entity/TangemPayCardNavigation.kt | 2 +- .../entity/TangemPayDetailsNavigation.kt | 9 + .../entity/TangemPayDetailsStateFactory.kt | 20 +- .../tangempay/entity/TangemPayDetailsUM.kt | 5 +- .../entity/TangemPayIssueAdditionalCardUM.kt | 10 + .../model/TangemPayAddToWalletModel.kt | 23 ++ .../tangempay/model/TangemPayCardPageModel.kt | 140 ++++++++-- .../tangempay/model/TangemPayDetailsModel.kt | 69 ++++- .../model/TangemPayEditDisplayNameModel.kt | 24 +- .../TangemPayIssueAdditionalCardModel.kt | 86 ++++++ .../TangemPayCardDetailsController.kt} | 114 ++++---- .../listener/CardDetailsEventListener.kt | 20 +- .../DefaultCardDetailsEventListener.kt | 6 +- .../TangemPayAccountDetailsInnerRoute.kt | 2 +- .../TangemPayCardDetailsInnerRoute.kt | 2 +- .../ui/TangemPayAddToWalletScreen.kt | 33 +-- .../ui/TangemPayAddToWalletScreenV2.kt | 33 +-- .../tangempay/ui/TangemPayCardDetailsBlock.kt | 2 +- .../tangempay/ui/TangemPayCardPageScreen.kt | 173 +++++++----- .../ui/TangemPayEditDisplayNameScreen.kt | 14 +- .../ui/TangemPayIssueAdditionalCardContent.kt | 211 ++++++++++++++ .../tangempay/utils/TangemPayDetailIntents.kt | 2 +- .../utils/TangemPayMessagesFactory.kt | 28 ++ .../TangemPayCardDetailsControllerTest.kt | 257 ++++++++++++++++++ 42 files changed, 1198 insertions(+), 394 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardScopedParams.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayIssueAdditionalCardComponent.kt delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/PreviewTangemPayCardDetailsBlockComponent.kt delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayIssueAdditionalCardUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayIssueAdditionalCardModel.kt rename features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/{TangemPayCardDetailsBlockModel.kt => controller/TangemPayCardDetailsController.kt} (68%) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayIssueAdditionalCardContent.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsControllerTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index a64c636127..2e2bd9d865 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -71,9 +71,11 @@ interface TangemPayApi { @GET("v1/customer/balance") suspend fun getCardBalance(@Header("Authorization") authHeader: String): ApiResponse - @POST("v1/customer/card/details") + /** Card-scoped reveal. `{card_id}` = selected card id. */ + @POST("v1/customer/card/{card_id}/details") suspend fun revealCardDetails( @Header("Authorization") authHeader: String, + @Path("card_id") cardId: String, @Body body: CardDetailsRequest, ): ApiResponse diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt index f63b6bf1d7..a72a37db8e 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -75,7 +75,10 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( ) } - override suspend fun revealCardDetails(userWalletId: UserWalletId): Either { + override suspend fun revealCardDetails( + userWalletId: UserWalletId, + cardId: String, + ): Either { return catch( block = { val publicKeyBase64 = getPublicKeyBase64() @@ -84,6 +87,7 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( requestHelper.performRequest(userWalletId = userWalletId) { authHeader -> tangemPayApi.revealCardDetails( authHeader = authHeader, + cardId = cardId, body = CardDetailsRequest(sessionId = sessionId), ) }.getOrNull()?.result, diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt index f455fe0128..b359525c20 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt @@ -35,6 +35,7 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( override suspend fun revealCardDetails( userWalletId: UserWalletId, + cardId: String, ): Either { if (isMockMode) { return TangemPayCardDetails( @@ -44,7 +45,7 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( expirationMonth = MOCK_EXPIRATION_MONTH, ).right() } - return real.revealCardDetails(userWalletId) + return real.revealCardDetails(userWalletId, cardId) } override suspend fun getPin(userWalletId: UserWalletId, cardId: String): Either { diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt index 78ecb05a81..95aa215164 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -15,7 +15,10 @@ interface TangemPayCardDetailsRepository { suspend fun getCardBalance(userWalletId: UserWalletId): Either - suspend fun revealCardDetails(userWalletId: UserWalletId): Either + suspend fun revealCardDetails( + userWalletId: UserWalletId, + cardId: String, + ): Either suspend fun getPin(userWalletId: UserWalletId, cardId: String): Either 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 a955c92974..7cc7dfeabb 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 @@ -274,13 +274,13 @@ sealed class TangemPayAnalyticsEvents( event = "Visa Add Extra Card Clicked", ) - class FakeDoorPopupDisplayed : TangemPayAnalyticsEvents( + class IssueAdditionalCardPopupShown : TangemPayAnalyticsEvents( categoryName = "Visa Card Management", - event = "Visa Fakedoor Popup Displayed", + event = "Visa Extra Card Issuance Popup Displayed", ) - class FakeDoorGotitClicked : TangemPayAnalyticsEvents( + class IssueAdditionalCardConfirmed : TangemPayAnalyticsEvents( categoryName = "Visa Card Management", - event = "Visa Fakedoor Gotit Clicked", + event = "Visa Extra Card Issuance Confirmed", ) } \ 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 d27bd58675..1363233241 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 @@ -65,9 +65,12 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru tokenReceiveComponentFactory = tokenReceiveComponentFactory, expressTransactionsComponentFactory = expressTransactionsComponentFactory, ) - TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( + is TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( context = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayCardPageComponent.Params(initialStatus = params.initialStatus), + params = TangemPayCardPageComponent.Params( + initialStatus = params.initialStatus, + cardId = config.cardId, + ), ) TangemPayAccountDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt index 2df7c0f605..45f16f29f4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt @@ -2,51 +2,42 @@ package com.tangem.features.tangempay.components import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider 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.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.core.ui.res.LocalVisaRedesignEnabled import com.tangem.features.tangempay.model.TangemPayAddToWalletModel import com.tangem.features.tangempay.ui.TangemPayAddToWalletScreen import com.tangem.features.tangempay.ui.TangemPayAddToWalletScreenV2 -import com.tangem.features.tangempay.utils.userWalletId internal class TangemPayAddToWalletComponent( private val appComponentContext: AppComponentContext, private val params: TangemPayDetailsContainerComponent.Params, ) : AppComponentContext by appComponentContext, ComposableContentComponent { - private val model: TangemPayAddToWalletModel = getOrCreateModel() - - private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( - appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params( - initialStatus = params.initialStatus, - userWalletId = params.initialStatus.userWalletId, - isEditingNameEnabled = false, - shouldShowCardDetailsButtonOnCard = true, - ), - ) + private val model: TangemPayAddToWalletModel = getOrCreateModel(params) @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val cardDetailsState by model.cardDetailsState.collectAsStateWithLifecycle() BackHandler(onBack = router::pop) - if (model.isRedesignEnabled()) { - TangemPayAddToWalletScreenV2( - state = state, - cardDetailsBlockComponent = cardDetailsBlockComponent, - ) - } else { - TangemPayAddToWalletScreen( - state = state, - cardDetailsBlockComponent = cardDetailsBlockComponent, - ) + CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) { + if (model.isRedesignEnabled()) { + TangemPayAddToWalletScreenV2( + state = state, + cardDetailsState = cardDetailsState, + ) + } else { + TangemPayAddToWalletScreen( + state = state, + cardDetailsState = cardDetailsState, + ) + } } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt index 5e1b79099b..0766a491e3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -84,9 +84,12 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus), ) - TangemPayCardDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( + is TangemPayCardDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus), + params = TangemPayCardScopedParams( + initialStatus = params.initialStatus, + cardId = config.cardId, + ), ) TangemPayCardDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), @@ -106,7 +109,10 @@ internal class TangemPayCardPageComponent @AssistedInject constructor( } } - data class Params(val initialStatus: AccountStatus.Payment) + data class Params( + val initialStatus: AccountStatus.Payment, + val cardId: String, + ) @AssistedFactory interface Factory : ComponentFactory { 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 cac0a0bdd9..c32727f8ce 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 @@ -10,7 +10,6 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.NavigationBar3ButtonsScrim @@ -18,12 +17,9 @@ 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 import com.tangem.features.tangempay.model.TangemPayCardPageModel import com.tangem.features.tangempay.ui.TangemPayCardPageScreen -import com.tangem.features.tangempay.utils.firstCard import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -35,16 +31,6 @@ internal class TangemPayCardPageScreenComponent( private val model: TangemPayCardPageModel = getOrCreateModel(params = params) - private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( - appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params( - initialStatus = params.initialStatus, - userWalletId = params.initialStatus.userWalletId, - isEditingNameEnabled = true, - shouldShowCardDetailsButtonOnCard = false, - ), - ) - private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, serializer = TangemPayCardNavigation.serializer(), @@ -55,15 +41,17 @@ internal class TangemPayCardPageScreenComponent( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + val cardControllers by model.cardControllersState.collectAsStateWithLifecycle() + val selectedCardId by model.selectedCardIdState.collectAsStateWithLifecycle() val bottomSheet by bottomSheetSlot.subscribeAsState() CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) { NavigationBar3ButtonsScrim() TangemPayCardPageScreen( state = state, - cardDetailsBlockComponent = cardDetailsBlockComponent, - cardDetailsState = cardDetailsState, + cardControllers = cardControllers, + selectedCardId = selectedCardId, + onCardSelect = model::onCardPageSelected, modifier = modifier, ) bottomSheet.child?.instance?.BottomSheet() @@ -89,7 +77,7 @@ internal class TangemPayCardPageScreenComponent( params = TangemPayReissueCardComponent.Params( listener = model, userWalletId = params.initialStatus.userWalletId, - cardId = params.initialStatus.firstCard().id, + cardId = navigation.cardId, ), ) is TangemPayCardNavigation.CloseCard -> TangemPayCloseCardComponent( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardScopedParams.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardScopedParams.kt new file mode 100644 index 0000000000..a366d9bf9b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardScopedParams.kt @@ -0,0 +1,12 @@ +package com.tangem.features.tangempay.components + +import com.tangem.domain.models.account.AccountStatus + +/** + * Params for sub-screens that operate on one explicit card (e.g. rename) instead of implicitly + * resolving the first card. Carries the [cardId] selected on the card page. + */ +internal data class TangemPayCardScopedParams( + val initialStatus: AccountStatus.Payment, + val cardId: String, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 32d51fc424..26e12e9976 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -128,6 +128,16 @@ internal class TangemPayDetailsComponent( listener = model, ), ) + is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent( + appComponentContext = context, + params = TangemPayIssueAdditionalCardComponent.Params( + userWalletId = navigation.walletId, + feeAmount = navigation.feeAmount, + feeCurrency = navigation.feeCurrency, + fiatBalance = navigation.fiatBalance, + listener = model, + ), + ) } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt index a9054040c6..54e8ca54d6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt @@ -2,41 +2,29 @@ package com.tangem.features.tangempay.components import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider 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.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.core.ui.res.LocalVisaRedesignEnabled import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel import com.tangem.features.tangempay.ui.TangemPayEditDisplayNameScreen -import com.tangem.features.tangempay.utils.userWalletId internal class TangemPayEditDisplayNameComponent( private val appComponentContext: AppComponentContext, - params: TangemPayDetailsContainerComponent.Params, + params: TangemPayCardScopedParams, ) : AppComponentContext by appComponentContext, ComposableContentComponent { private val model: TangemPayEditDisplayNameModel = getOrCreateModel(params) - private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( - appComponentContext = child("editDisplayNameCardDetails"), - params = TangemPayCardDetailsBlockComponent.Params( - initialStatus = params.initialStatus, - userWalletId = params.initialStatus.userWalletId, - isEditingNameEnabled = false, - shouldShowCardDetailsButtonOnCard = false, - ), - ) - @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + val cardDetailsState by model.cardDetailsState.collectAsStateWithLifecycle() val editingCardDetailsState = cardDetailsState.copy( displayNameState = DisplayNameState.Editing( displayName = state.editingValue.text, @@ -48,12 +36,13 @@ internal class TangemPayEditDisplayNameComponent( ), ) BackHandler(onBack = state.onDismiss) - TangemPayEditDisplayNameScreen( - state = state, - cardDetailsBlockComponent = cardDetailsBlockComponent, - cardDetailsState = editingCardDetailsState, - modifier = modifier, - isRedesignEnabled = model.isRedesignEnabled(), - ) + CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) { + TangemPayEditDisplayNameScreen( + state = state, + cardDetailsState = editingCardDetailsState, + modifier = modifier, + isRedesignEnabled = model.isRedesignEnabled(), + ) + } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayIssueAdditionalCardComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayIssueAdditionalCardComponent.kt new file mode 100644 index 0000000000..6ca02adb57 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayIssueAdditionalCardComponent.kt @@ -0,0 +1,45 @@ +package com.tangem.features.tangempay.components + +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.serialization.SerializedBigDecimal +import com.tangem.domain.models.serialization.SerializedCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.tangempay.model.TangemPayIssueAdditionalCardModel +import com.tangem.features.tangempay.ui.TangemPayIssueAdditionalCardContent + +internal class TangemPayIssueAdditionalCardComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayIssueAdditionalCardModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + TangemPayIssueAdditionalCardContent(state = state) + } + + data class Params( + val userWalletId: UserWalletId, + val feeAmount: SerializedBigDecimal, + val feeCurrency: SerializedCurrency, + val fiatBalance: SerializedBigDecimal, + val listener: Listener, + ) + + interface Listener { + fun onIssueAdditionalCardDismissed() + fun onIssueAdditionalCardSucceeded() + fun onAddFundsForCardIssue() + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt deleted file mode 100644 index bbe8e07c3f..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/DefaultTangemPayCardDetailsBlockComponent.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.features.tangempay.components.cardDetails - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.res.LocalVisaRedesignEnabled -import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM -import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel -import com.tangem.features.tangempay.ui.TangemPayCard -import kotlinx.coroutines.flow.StateFlow - -internal class DefaultTangemPayCardDetailsBlockComponent( - appComponentContext: AppComponentContext, - params: TangemPayCardDetailsBlockComponent.Params, -) : AppComponentContext by appComponentContext, TangemPayCardDetailsBlockComponent { - - private val model: TangemPayCardDetailsBlockModel = getOrCreateModel(params = params) - override val state: StateFlow = model.uiState - - @Composable - override fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) { - CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) { - TangemPayCard(state, modifier) - } - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/PreviewTangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/PreviewTangemPayCardDetailsBlockComponent.kt deleted file mode 100644 index 8a4043c5ee..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/PreviewTangemPayCardDetailsBlockComponent.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.features.tangempay.components.cardDetails - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM -import com.tangem.features.tangempay.ui.TangemPayCard -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -internal class PreviewTangemPayCardDetailsBlockComponent( - state: TangemPayCardDetailsUM, -) : TangemPayCardDetailsBlockComponent { - - override val state: StateFlow = MutableStateFlow(state) - - @Composable - override fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) { - TangemPayCard(state, modifier) - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt deleted file mode 100644 index 849d447921..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.features.tangempay.components.cardDetails - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.ui.Modifier -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM -import kotlinx.coroutines.flow.StateFlow - -@Stable -internal interface TangemPayCardDetailsBlockComponent { - val state: StateFlow - - @Composable - fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) - - data class Params( - val initialStatus: AccountStatus.Payment, - val userWalletId: UserWalletId, - val isEditingNameEnabled: Boolean, - val shouldShowCardDetailsButtonOnCard: Boolean, - ) -} \ 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 165d7cb95a..0cc4f2aa1d 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,19 +2,9 @@ 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.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 -import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel -import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel -import com.tangem.features.tangempay.model.TangemPayTxHistoryModel -import com.tangem.features.tangempay.model.TangemPayReissueCardModel -import com.tangem.features.tangempay.model.TangemPayViewPinModel +import com.tangem.features.tangempay.model.* import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -45,11 +35,6 @@ internal interface TangemPayModelModule { @ClassKey(TangemPayChangePinModel::class) fun bindTangemPayChangePinModel(model: TangemPayChangePinModel): Model - @Binds - @IntoMap - @ClassKey(TangemPayCardDetailsBlockModel::class) - fun bindTangemPayCardDetailsBlockModel(model: TangemPayCardDetailsBlockModel): Model - @Binds @IntoMap @ClassKey(TangemPayAddToWalletModel::class) @@ -75,6 +60,11 @@ internal interface TangemPayModelModule { @ClassKey(TangemPayEditDisplayNameModel::class) fun bindTangemPayEditDisplayNameModel(model: TangemPayEditDisplayNameModel): Model + @Binds + @IntoMap + @ClassKey(TangemPayIssueAdditionalCardModel::class) + fun bindTangemPayIssueAdditionalCardModel(model: TangemPayIssueAdditionalCardModel): Model + @Binds @IntoMap @ClassKey(TangemPayReissueCardModel::class) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt index 39b7c9d84a..7e6ba3c8d0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -4,7 +4,6 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.model.CardDataType import com.tangem.utils.StringsSigns @Suppress("LongParameterList") 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 eef562c6c5..425d6813f8 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 @@ -15,7 +15,7 @@ internal sealed class TangemPayCardNavigation { ) : TangemPayCardNavigation() @Serializable - data object ReissueCard : TangemPayCardNavigation() + data class ReissueCard(val cardId: String) : TangemPayCardNavigation() @Serializable data class CloseCard( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt index 5f3ba814f9..b3426e9094 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt @@ -3,6 +3,7 @@ package com.tangem.features.tangempay.entity import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.serialization.SerializedBigDecimal +import com.tangem.domain.models.serialization.SerializedCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.visa.model.TangemPayTxHistoryItem import kotlinx.serialization.Serializable @@ -27,4 +28,12 @@ internal sealed class TangemPayDetailsNavigation { val transaction: TangemPayTxHistoryItem, val isBalanceHidden: Boolean, ) : TangemPayDetailsNavigation() + + @Serializable + data class IssueAdditionalCard( + val walletId: UserWalletId, + val feeAmount: SerializedBigDecimal, + val feeCurrency: SerializedCurrency, + val fiatBalance: SerializedBigDecimal, + ) : TangemPayDetailsNavigation() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index ea4b2a37a6..46c249f8f3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -28,6 +28,7 @@ internal class TangemPayDetailsStateFactory( private val intents: TangemPayDetailIntents, private val isRedesignEnabled: Boolean, private val isRemoveAccountEnabled: Boolean, + private val isMultipleCardsEnabled: Boolean, ) { fun getLoadingState(): TangemPayDetailsUM { return TangemPayDetailsUM( @@ -79,17 +80,18 @@ internal class TangemPayDetailsStateFactory( (card == null || card.frozenState == TangemPayCardFrozenState.Unfrozen), ), cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState( - cards = card?.let { - persistentListOf( + cards = status.cards + .let { if (isMultipleCardsEnabled) it else it.take(1) } + .map { cardItem -> TangemPayDetailsBalanceBlockState.Card( - lastDigits = card.lastDigits, - onClick = intents::onCardClick, - isReissuing = card.state != TangemPayCardState.Active, + lastDigits = cardItem.lastDigits, + onClick = { intents.onCardClick(cardItem.id) }, + isReissuing = cardItem.state != TangemPayCardState.Active, isEnabled = errorNotificationConfig == null, - isFrozen = card.isFrozen, - ), - ) - } ?: persistentListOf(), + isFrozen = cardItem.isFrozen, + ) + } + .toImmutableList(), onAddCardClick = intents::onAddCardClick, ), ), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 61ca2044bf..02e00f3fdd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -7,9 +7,12 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.pay.TangemPayCardFrozenState -import com.tangem.features.tangempay.model.CardDataType import kotlinx.collections.immutable.ImmutableList +internal enum class CardDataType { + Number, Expiry, CVV +} + internal data class TangemPayDetailsUM( val topBarConfig: TangemPayDetailsTopBarConfig, val pullToRefreshConfig: PullToRefreshConfig, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayIssueAdditionalCardUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayIssueAdditionalCardUM.kt new file mode 100644 index 0000000000..f1608c7df2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayIssueAdditionalCardUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.tangempay.entity + +internal data class TangemPayIssueAdditionalCardUM( + val isBalanceInsufficient: Boolean, + val feeText: String, + val isLoading: Boolean, + val onIssueClick: () -> Unit, + val onAddFundsClick: () -> Unit, + val onDismiss: () -> Unit, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddToWalletModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddToWalletModel.kt index 1bb10640db..c855384807 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddToWalletModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddToWalletModel.kt @@ -3,13 +3,19 @@ package com.tangem.features.tangempay.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.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.tangempay.TangemPayFeatureToggles +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayAddToWalletStepItemUM import com.tangem.features.tangempay.entity.TangemPayAddToWalletUM +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController import com.tangem.features.tangempay.utils.GoogleWalletUtil +import com.tangem.features.tangempay.utils.firstCard +import com.tangem.features.tangempay.utils.userWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow @@ -19,12 +25,29 @@ import javax.inject.Inject @Stable @ModelScoped internal class TangemPayAddToWalletModel @Inject constructor( + paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val googleWalletUtil: GoogleWalletUtil, private val featureToggles: TangemPayFeatureToggles, + cardDetailsControllerFactory: TangemPayCardDetailsController.Factory, ) : Model() { + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + + private val cardDetailsController = cardDetailsControllerFactory.create( + scope = modelScope, + initialCard = params.initialStatus.firstCard(), + userWalletId = params.initialStatus.userWalletId, + config = TangemPayCardDetailsController.Config( + isEditingNameEnabled = false, + shouldShowCardDetailsButtonOnCard = true, + ), + onEditNameClick = {}, + ) + + val cardDetailsState: StateFlow = cardDetailsController.uiState + val uiState: StateFlow field = MutableStateFlow(getInitialState()) 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 f4f0a87b2f..20cd8763fb 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 @@ -28,6 +28,7 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.findCardWithId import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.models.pay.TangemPayCardState @@ -46,16 +47,23 @@ import com.tangem.features.tangempay.components.TangemPayCardPageComponent import com.tangem.features.tangempay.components.ViewPinListener import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.* +import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController import com.tangem.features.tangempay.model.listener.CardDetailsEvent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute -import com.tangem.features.tangempay.utils.* +import com.tangem.features.tangempay.utils.TangemPayMessagesFactory +import com.tangem.features.tangempay.utils.cryptoCurrency +import com.tangem.features.tangempay.utils.ifLoadedOrNull +import com.tangem.features.tangempay.utils.userWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -76,6 +84,7 @@ internal class TangemPayCardPageModel @Inject constructor( private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase, private val cardDetailsEventListener: CardDetailsEventListener, private val tangemPayFeatureToggles: TangemPayFeatureToggles, + private val cardDetailsControllerFactory: TangemPayCardDetailsController.Factory, ) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener, CloseCardListener { private val params: TangemPayCardPageComponent.Params = paramsContainer.require() @@ -86,12 +95,27 @@ internal class TangemPayCardPageModel @Inject constructor( private val reloadLimitsJobHolder = JobHolder() private val currentStatus = MutableStateFlow(params.initialStatus) - private val initialCardId = params.initialStatus.firstCard().id private val userWalletId = currentStatus.value.userWalletId + /** + * Currently focused card in the swipe pager. Per-card actions and the "Details" reveal target it. + * Initialized to the card the user tapped on the account screen ([Params.cardId]); falls back to + * the first available card if it is gone (handled in [syncCardControllers]). + */ + private val selectedCardId = MutableStateFlow(params.cardId) + val selectedCardIdState: StateFlow = selectedCardId + + private val cardControllers = linkedMapOf() + private val _cardControllersState: MutableStateFlow> = + MutableStateFlow(persistentListOf()) + val cardControllersState: StateFlow> = _cardControllersState + private val cryptoCurrency get() = currentStatus.value.cryptoCurrency + // Multiple cards are temporarily gated behind the same toggle as close-card. + private val isMultipleCardsEnabled: Boolean get() = tangemPayFeatureToggles.isCloseCardEnabled + val uiState: StateFlow field = MutableStateFlow( TangemPayCardPageUM( @@ -115,28 +139,24 @@ internal class TangemPayCardPageModel @Inject constructor( paymentAccountStatusSupplier.invoke(userWalletId) .onEach { state -> currentStatus.update { state } - uiState.update { it.copy(dailyLimitState = buildDailyLimitState(state)) } - - val status = state.value - if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) { - val card = state.findCard(initialCardId, params.initialStatus) ?: return@onEach - uiState.update { uiState -> - uiState.copy( - settings = buildSettings(card), - settingsV2 = buildSettingsV2(card), - menuItems = buildMenuItems(isLastCard = status.cards.isLastCard()), - cardState = card.state, - ) - } - } + syncCardControllers(state) } .launchIn(modelScope) + + combine(currentStatus, selectedCardId) { status, selectedId -> status to selectedId } + .onEach { (status, selectedId) -> updateSelectedCardUi(status, selectedId) } + .launchIn(modelScope) + } + + override fun onDestroy() { + cardDetailsEventListener.send(CardDetailsEvent.HideAll) + super.onDestroy() } private fun buildDailyLimitState(state: AccountStatus.Payment): TangemPayDailyLimitBlockState { val status = state.value val card = if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) { - state.findCard(initialCardId, params.initialStatus) + status.findCardWithId(selectedCardId.value) } else { null } @@ -157,6 +177,73 @@ internal class TangemPayCardPageModel @Inject constructor( fun isRedesignEnabled(): Boolean = tangemPayFeatureToggles.isRedesignEnabled + /** Reports the card the user swiped to so per-card UI and reveal target it. */ + fun onCardPageSelected(index: Int) { + cardControllersState.value.getOrNull(index)?.let { selectedCardId.value = it.cardId } + } + + private fun childCardScope(): CoroutineScope = + CoroutineScope(modelScope.coroutineContext + SupervisorJob(modelScope.coroutineContext[Job])) + + private fun syncCardControllers(state: AccountStatus.Payment) { + val status = state.value + if (status !is PaymentAccountStatusValue.Loaded || status.source != StatusSource.ACTUAL) return + + val cards = status.cards.let { if (isMultipleCardsEnabled) it else it.take(1) } + val newIds = cards.mapTo(mutableSetOf()) { it.id } + + cardControllers.keys.filterNot { it in newIds }.toList().forEach { removedId -> + cardControllers.remove(removedId)?.let { controller -> + controller.dispose() + cardDetailsEventListener.send(CardDetailsEvent.Hide(removedId)) + } + } + + val ordered = LinkedHashMap(cards.size) + cards.forEach { card -> + ordered[card.id] = cardControllers[card.id] ?: cardDetailsControllerFactory.create( + scope = childCardScope(), + initialCard = card, + userWalletId = userWalletId, + config = TangemPayCardDetailsController.Config( + isEditingNameEnabled = true, + shouldShowCardDetailsButtonOnCard = false, + ), + onEditNameClick = { router.push(TangemPayCardDetailsInnerRoute.EditCardDisplayName(card.id)) }, + ) + } + cardControllers.clear() + cardControllers.putAll(ordered) + _cardControllersState.value = ordered.values.toList().toImmutableList() + + if (selectedCardId.value !in newIds) { + ordered.keys.firstOrNull()?.let { selectedCardId.value = it } + } + } + + private fun updateSelectedCardUi(state: AccountStatus.Payment, selectedId: String) { + val status = state.value + if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) { + val card = status.findCardWithId(selectedId) ?: return + uiState.update { uiState -> + uiState.copy( + dailyLimitState = buildDailyLimitState(state), + settings = buildSettings(card), + settingsV2 = buildSettingsV2(card), + menuItems = buildMenuItems(isLastCard = status.cards.isLastCard()), + cardState = card.state, + ) + } + } else { + uiState.update { it.copy(dailyLimitState = buildDailyLimitState(state)) } + } + } + + private fun selectedCard(): TangemPayCard? { + val status = currentStatus.value.value + return (status as? PaymentAccountStatusValue.Loaded)?.findCardWithId(selectedCardId.value) + } + private fun buildSettings(card: TangemPayCard): ImmutableList { if (isRedesignEnabled()) return persistentListOf() return persistentListOf( @@ -185,8 +272,9 @@ internal class TangemPayCardPageModel @Inject constructor( private suspend fun subscribeOnDetailsState() { if (!isRedesignEnabled()) return - cardDetailsEventListener.event.collect { event -> - val isDetailsShown = event == CardDetailsEvent.Show + combine(cardDetailsEventListener.event, selectedCardId) { event, selectedId -> + event is CardDetailsEvent.Show && event.cardId == selectedId + }.collect { isDetailsShown -> uiState.update { state -> state.copy( settingsV2 = state.settingsV2 @@ -277,9 +365,7 @@ internal class TangemPayCardPageModel @Inject constructor( } private fun onClickViewDetails() { - modelScope.launch(dispatchers.default) { - cardDetailsEventListener.send(CardDetailsEvent.Show) - } + cardDetailsEventListener.send(CardDetailsEvent.Show(selectedCardId.value)) } private fun onClickReloadLimits() { @@ -300,7 +386,7 @@ internal class TangemPayCardPageModel @Inject constructor( if (!isPinSet) { router.push(TangemPayCardDetailsInnerRoute.ChangePIN) } else { - val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return + val card = selectedCard() ?: return bottomSheetNavigation.activate( TangemPayCardNavigation.ViewPinCode( userWalletId = userWalletId, @@ -329,7 +415,7 @@ internal class TangemPayCardPageModel @Inject constructor( private fun onClickReissueCard() { analytics.send(TangemPayAnalyticsEvents.ReplaceCardClicked()) - bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard) + bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard(cardId = selectedCardId.value)) } override fun onDismissReissueCard() { @@ -342,7 +428,7 @@ internal class TangemPayCardPageModel @Inject constructor( private fun onClickCloseCard() { analytics.send(TangemPayAnalyticsEvents.CloseCardClicked()) - val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return + val card = selectedCard() ?: return bottomSheetNavigation.activate( TangemPayCardNavigation.CloseCard( userWalletId = userWalletId, @@ -407,7 +493,7 @@ internal class TangemPayCardPageModel @Inject constructor( } private fun freezeCard() { - val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return + val card = selectedCard() ?: return modelScope.launch { changeCardFrozenStateUseCase( userWalletId = userWalletId, @@ -424,7 +510,7 @@ internal class TangemPayCardPageModel @Inject constructor( } private fun unfreezeCard() { - val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return + val card = selectedCard() ?: return modelScope.launch { changeCardFrozenStateUseCase( userWalletId = userWalletId, 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 60462a0036..0607216242 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 @@ -32,6 +32,7 @@ import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayWithdrawRepository +import com.tangem.domain.pay.usecase.GetCustomerOffersUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.model.TangemPayTxHistoryItem @@ -39,6 +40,7 @@ import com.tangem.features.tangempay.TangemPayConstants import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tangempay.components.AddFundsListener import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayIssueAdditionalCardComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation @@ -56,6 +58,7 @@ import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import java.math.BigDecimal import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -79,7 +82,12 @@ internal class TangemPayDetailsModel @Inject constructor( private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, private val produceTangemPayInitialDataUseCase: ProduceTangemPayInitialDataUseCase, private val onboardingRepository: OnboardingRepository, -) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener { + private val getCustomerOffers: GetCustomerOffersUseCase, +) : Model(), + TangemPayTxHistoryUiActions, + TangemPayDetailIntents, + AddFundsListener, + TangemPayIssueAdditionalCardComponent.Listener { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() @@ -91,12 +99,16 @@ internal class TangemPayDetailsModel @Inject constructor( val cryptoCurrency get() = currentStatus.value.cryptoCurrency + // Multiple cards are temporarily gated behind the same toggle as close-card. + private val isMultipleCardsEnabled: Boolean get() = tangemPayFeatureToggles.isCloseCardEnabled + private val stateFactory = TangemPayDetailsStateFactory( onBack = router::pop, onOpenMenu = ::onOpenMenu, intents = this, isRedesignEnabled = isRedesignEnabled(), isRemoveAccountEnabled = tangemPayFeatureToggles.isRemoveAccountEnabled, + isMultipleCardsEnabled = isMultipleCardsEnabled, ) val uiState: StateFlow @@ -349,19 +361,54 @@ internal class TangemPayDetailsModel @Inject constructor( urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } - override fun onCardClick() { + override fun onCardClick(cardId: String) { analytics.send(TangemPayAnalyticsEvents.CardIconClicked()) - router.push(TangemPayAccountDetailsInnerRoute.CardDetails) + router.push(TangemPayAccountDetailsInnerRoute.CardDetails(cardId = cardId)) } override fun onAddCardClick() { analytics.send(TangemPayAnalyticsEvents.AddExtraCardClicked()) - analytics.send(TangemPayAnalyticsEvents.FakeDoorPopupDisplayed()) - uiMessageSender.send( - message = TangemPayMessagesFactory.createFutureFeature( - onGotItClick = { analytics.send(TangemPayAnalyticsEvents.FakeDoorGotitClicked()) }, - ), - ) + if (!isMultipleCardsEnabled) { + uiMessageSender.send(TangemPayMessagesFactory.createFutureFeature(onGotItClick = {})) + return + } + val activeCardsCount = currentStatus.value.ifLoadedOrNull { loaded -> + loaded.cards.count { it.cardStatus.isActive } + } ?: 0 + if (activeCardsCount >= MAX_ACTIVE_CARDS) { + uiMessageSender.send(TangemPayMessagesFactory.createMaximumCardsIssued(maxCards = MAX_ACTIVE_CARDS)) + return + } + modelScope.launch { + val offer = getCustomerOffers.additionalCardOffer(userWalletId).getOrNull() + if (offer == null) { + uiMessageSender.send(message = TangemPayMessagesFactory.createGenericError()) + return@launch + } + analytics.send(TangemPayAnalyticsEvents.IssueAdditionalCardPopupShown()) + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.IssueAdditionalCard( + walletId = userWalletId, + feeAmount = offer.fee.amount, + feeCurrency = offer.fee.currency, + fiatBalance = currentStatus.value.balanceOrNull()?.fiatBalance?.availableBalance ?: BigDecimal.ZERO, + ), + ) + } + } + + override fun onIssueAdditionalCardDismissed() { + bottomSheetNavigation.dismiss() + } + + override fun onIssueAdditionalCardSucceeded() { + bottomSheetNavigation.dismiss() + modelScope.launch { paymentAccountStatusFetcher.invoke(userWalletId) } + } + + override fun onAddFundsForCardIssue() { + bottomSheetNavigation.dismiss() + onClickAddFunds() } override fun onRenewSession() { @@ -412,4 +459,8 @@ internal class TangemPayDetailsModel @Inject constructor( private fun showBottomSheetError(type: TangemPayDetailsErrorType) { uiMessageSender.send(message = TangemPayMessagesFactory.createErrorMessage(errorType = type)) } + + private companion object { + const val MAX_ACTIVE_CARDS = 3 + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt index b5330e2d0a..7307374617 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -19,10 +19,12 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase import com.tangem.features.tangempay.TangemPayFeatureToggles -import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayCardScopedParams import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM -import com.tangem.features.tangempay.utils.firstCard +import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController +import com.tangem.features.tangempay.utils.requireLoaded import com.tangem.features.tangempay.utils.userWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* @@ -40,13 +42,27 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( private val uiMessageSender: UiMessageSender, private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val featureToggles: TangemPayFeatureToggles, + cardDetailsControllerFactory: TangemPayCardDetailsController.Factory, ) : Model() { - private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + private val params: TangemPayCardScopedParams = paramsContainer.require() - private val card = params.initialStatus.firstCard() + private val card = params.initialStatus.requireLoaded().requireCardWithId(params.cardId) private val originalDisplayName = card.displayName?.value.orEmpty() + private val cardDetailsController = cardDetailsControllerFactory.create( + scope = modelScope, + initialCard = card, + userWalletId = params.initialStatus.userWalletId, + config = TangemPayCardDetailsController.Config( + isEditingNameEnabled = false, + shouldShowCardDetailsButtonOnCard = false, + ), + onEditNameClick = {}, + ) + + val cardDetailsState: StateFlow = cardDetailsController.uiState + val uiState: StateFlow field = MutableStateFlow( TangemPayEditDisplayNameUM( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayIssueAdditionalCardModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayIssueAdditionalCardModel.kt new file mode 100644 index 0000000000..d698b2b52d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayIssueAdditionalCardModel.kt @@ -0,0 +1,86 @@ +package com.tangem.features.tangempay.model + +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.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.pay.usecase.IssueAdditionalCardUseCase +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.features.tangempay.components.TangemPayIssueAdditionalCardComponent +import com.tangem.features.tangempay.entity.TangemPayIssueAdditionalCardUM +import com.tangem.features.tangempay.utils.TangemPayMessagesFactory +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 TangemPayIssueAdditionalCardModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val issueAdditionalCard: IssueAdditionalCardUseCase, + private val uiMessageSender: UiMessageSender, + private val analytics: AnalyticsEventHandler, +) : Model() { + + private val params: TangemPayIssueAdditionalCardComponent.Params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow(buildInitialState()) + + private fun buildInitialState(): TangemPayIssueAdditionalCardUM { + return TangemPayIssueAdditionalCardUM( + isBalanceInsufficient = params.fiatBalance < params.feeAmount, + feeText = formatFee(), + isLoading = false, + onIssueClick = ::onIssueClick, + onAddFundsClick = { params.listener.onAddFundsForCardIssue() }, + onDismiss = ::onDismiss, + ) + } + + fun onDismiss() { + params.listener.onIssueAdditionalCardDismissed() + } + + private fun onIssueClick() { + analytics.send(TangemPayAnalyticsEvents.IssueAdditionalCardConfirmed()) + uiState.update { it.copy(isLoading = true) } + modelScope.launch { + issueAdditionalCard(userWalletId = params.userWalletId).fold( + ifLeft = ::handleIssueError, + ifRight = { + uiState.update { uiState -> uiState.copy(isLoading = false) } + params.listener.onIssueAdditionalCardSucceeded() + }, + ) + } + } + + private fun handleIssueError(error: VisaApiError) { + when (error) { + VisaApiError.CardIssueInsufficientBalance -> uiState.update { it.copy(isBalanceInsufficient = true) } + else -> { + uiMessageSender.send(message = TangemPayMessagesFactory.createGenericError()) + onDismiss() + } + } + } + + private fun formatFee(): String { + return params.feeAmount.format { + fiat( + fiatCurrencyCode = params.feeCurrency.currencyCode, + fiatCurrencySymbol = params.feeCurrency.symbol, + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt similarity index 68% rename from features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt rename to features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt index a1d6822bf7..709b12c995 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt @@ -1,25 +1,23 @@ -package com.tangem.features.tangempay.model +package com.tangem.features.tangempay.model.controller 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.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.findCardWithId +import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents -import com.tangem.features.tangempay.TangemPayFeatureToggles -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.CardDataType import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.listener.CardDetailsEvent @@ -28,51 +26,61 @@ import com.tangem.features.tangempay.model.transformers.DetailsHiddenStateTransf import com.tangem.features.tangempay.model.transformers.DetailsRevealProgressStateTransformer import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTransformer import com.tangem.features.tangempay.model.transformers.TangemPayCardDetailsUpdateNameTransformer -import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute -import com.tangem.features.tangempay.utils.findCard -import com.tangem.features.tangempay.utils.firstCard import com.tangem.utils.StringsSigns -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import com.tangem.utils.transformer.update +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import javax.inject.Inject import com.tangem.utils.transformer.update as transformerUpdate private const val SHOW_DETAILS_TIME = 30_000L +/** + * Owns the UI state of a single TangemPay card-detail block (the flip card with reveal PAN/CVV, + * freeze badge, display-name editing). Several controllers can be alive at once — e.g. one per card + * in a swipe pager — so all logic is scoped to [cardId] and reveal/hide is coordinated through the + * card-scoped [CardDetailsEventListener]. + * + + * lifecycle is owned by the host model, which passes a child [scope] and calls [dispose] when the + * card disappears. + */ @Suppress("LongParameterList") @Stable -@ModelScoped -internal class TangemPayCardDetailsBlockModel @Inject constructor( - paramsContainer: ParamsContainer, - override val dispatchers: CoroutineDispatcherProvider, +internal class TangemPayCardDetailsController @AssistedInject constructor( + @Assisted private val scope: CoroutineScope, + @Assisted private val initialCard: TangemPayCard, + @Assisted private val userWalletId: UserWalletId, + @Assisted private val config: Config, + @Assisted private val onEditNameClick: () -> Unit, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, private val cardDetailsEventListener: CardDetailsEventListener, private val analytics: AnalyticsEventHandler, - private val router: Router, private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, - private val payFeatureToggles: TangemPayFeatureToggles, -) : Model() { +) { + + val cardId: String = initialCard.id - private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() - private val initialCard = params.initialStatus.firstCard() private var frozenStateJob: Job? = null private val stateFactory = TangemPayCardDetailsBlockStateFactory( cardNumberEnd = initialCard.lastDigits, displayName = initialCard.displayName, - isEditingNameEnabled = params.isEditingNameEnabled, - onEditNameClick = ::startEditingDisplayName, + isEditingNameEnabled = config.isEditingNameEnabled, + onEditNameClick = onEditNameClick, onReveal = ::requestReveal, onCopy = ::copyData, - shouldShowCardDetailsButtonOnCard = params.shouldShowCardDetailsButtonOnCard, + shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard, ) val uiState: StateFlow @@ -83,24 +91,27 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( init { subscribeToCardChanges() - modelScope.launch { - cardDetailsEventListener.event.collectLatest { event -> + scope.launch { + cardDetailsEventListener.event.collect { event -> when (event) { - CardDetailsEvent.Hide -> hideCardDetails() - CardDetailsEvent.Show -> revealCardDetails() + is CardDetailsEvent.Show -> if (event.cardId == cardId) revealCardDetails() else hideCardDetails() + is CardDetailsEvent.Hide -> if (event.cardId == cardId) hideCardDetails() + CardDetailsEvent.HideAll -> hideCardDetails() } } } } - fun isRedesignEnabled(): Boolean = payFeatureToggles.isRedesignEnabled + fun dispose() { + scope.cancel() + } private fun subscribeToCardChanges() { - paymentAccountStatusSupplier.invoke(params.userWalletId) + paymentAccountStatusSupplier.invoke(userWalletId) .onEach { state -> val status = state.value if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) { - val card = state.findCard(initialCard.id, params.initialStatus) ?: return@onEach + val card = status.findCardWithId(cardId) ?: return@onEach if (card.state != TangemPayCardState.Active) { requestHide() } @@ -115,7 +126,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( subscribeToCardFrozenState(card.id) } } - .launchIn(modelScope) + .launchIn(scope) } private fun subscribeToCardFrozenState(cardId: String) { @@ -126,24 +137,24 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( uiState.update { state -> state.copy(cardFrozenState = TangemPayCardFrozenState.Pending) } } } - .launchIn(modelScope) + .launchIn(scope) } private fun requestReveal() { - modelScope.launch { cardDetailsEventListener.send(CardDetailsEvent.Show) } + cardDetailsEventListener.send(CardDetailsEvent.Show(cardId)) } private fun requestHide() { - modelScope.launch { cardDetailsEventListener.send(CardDetailsEvent.Hide) } + cardDetailsEventListener.send(CardDetailsEvent.Hide(cardId)) } private fun revealCardDetails() { analytics.send(TangemPayAnalyticsEvents.ViewCardDetailsClicked()) - modelScope.launch { + scope.launch { uiState.transformerUpdate( transformer = DetailsRevealProgressStateTransformer(onClickHide = ::requestHide), ) - cardDetailsRepository.revealCardDetails(params.userWalletId) + cardDetailsRepository.revealCardDetails(userWalletId, cardId) .onRight { cardDetails -> uiState.transformerUpdate( transformer = DetailsRevealedStateTransformer( @@ -157,7 +168,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( uiState.transformerUpdate( transformer = DetailsHiddenStateTransformer( stateFactory = stateFactory, - shouldShowCardDetailsButtonOnCard = params.shouldShowCardDetailsButtonOnCard, + shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard, ), ) showError() @@ -166,7 +177,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } private fun launchShowDetailsTimer() { - modelScope.launch { + scope.launch { delay(SHOW_DETAILS_TIME) requestHide() }.saveIn(showCardDetailsTimerJobHolder) @@ -177,7 +188,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( uiState.transformerUpdate( transformer = DetailsHiddenStateTransformer( stateFactory = stateFactory, - shouldShowCardDetailsButtonOnCard = params.shouldShowCardDetailsButtonOnCard, + shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard, ), ) } @@ -188,10 +199,6 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( ) } - private fun startEditingDisplayName() { - router.push(TangemPayCardDetailsInnerRoute.EditCardDisplayName) - } - private fun copyData(text: String, type: CardDataType) { val event = when (type) { CardDataType.Number -> TangemPayAnalyticsEvents.CopyCardNumberClicked() @@ -201,8 +208,21 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( analytics.send(event) clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true) } -} -internal enum class CardDataType { - Number, Expiry, CVV + /** Per-block configuration that does not depend on live card data. */ + data class Config( + val isEditingNameEnabled: Boolean, + val shouldShowCardDetailsButtonOnCard: Boolean, + ) + + @AssistedFactory + interface Factory { + fun create( + scope: CoroutineScope, + initialCard: TangemPayCard, + userWalletId: UserWalletId, + config: Config, + onEditNameClick: () -> Unit, + ): TangemPayCardDetailsController + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/CardDetailsEventListener.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/CardDetailsEventListener.kt index 94b0a1d1d7..b7e8bc9ebe 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/CardDetailsEventListener.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/CardDetailsEventListener.kt @@ -3,15 +3,27 @@ package com.tangem.features.tangempay.model.listener import kotlinx.coroutines.flow.Flow /** - * Card details must be hidden after some UI actions from the external component + * Coordinates reveal/hide of card details across card-detail blocks that may be shown simultaneously + * (e.g. several cards in a pager, or the same card on the card page and the edit-name screen). + * + * Events are card-scoped so that one block does not flip the others; [CardDetailsEvent.HideAll] is + * the only broadcast event. */ internal interface CardDetailsEventListener { val event: Flow - suspend fun send(event: CardDetailsEvent) + fun send(event: CardDetailsEvent) } -internal enum class CardDetailsEvent { - Hide, Show +internal sealed interface CardDetailsEvent { + + /** Reveal the details of [cardId]. Other blocks treat it as a hint to hide themselves. */ + data class Show(val cardId: String) : CardDetailsEvent + + /** Hide the details of [cardId]. */ + data class Hide(val cardId: String) : CardDetailsEvent + + /** Hide details of every block (e.g. leaving the card screen). */ + data object HideAll : CardDetailsEvent } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/DefaultCardDetailsEventListener.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/DefaultCardDetailsEventListener.kt index 7c7b3a3dbf..4de1ece210 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/DefaultCardDetailsEventListener.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/listener/DefaultCardDetailsEventListener.kt @@ -16,7 +16,9 @@ internal class DefaultCardDetailsEventListener @Inject constructor() : CardDetai ) override val event: Flow = _event - override suspend fun send(event: CardDetailsEvent) { - _event.emit(event) + // tryEmit never fails with replay=1 + DROP_OLDEST, so send can stay non-suspending and be called + // from non-coroutine contexts (e.g. Model.onDestroy). + override fun send(event: CardDetailsEvent) { + _event.tryEmit(event) } } \ No newline at end of file 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 8ef5c5e9a3..c928fbdeb0 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 @@ -9,7 +9,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route { data object AccountDetails : TangemPayAccountDetailsInnerRoute() @Serializable - data object CardDetails : TangemPayAccountDetailsInnerRoute() + data class CardDetails(val cardId: String) : TangemPayAccountDetailsInnerRoute() @Serializable data object AddToWallet : TangemPayAccountDetailsInnerRoute() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt index 38d9fb1d83..299f280cb1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt @@ -19,7 +19,7 @@ internal sealed class TangemPayCardDetailsInnerRoute : Route { data object AddToWallet : TangemPayCardDetailsInnerRoute() @Serializable - data object EditCardDisplayName : TangemPayCardDetailsInnerRoute() + data class EditCardDisplayName(val cardId: String) : TangemPayCardDetailsInnerRoute() @Serializable data object LimitSetup : TangemPayCardDetailsInnerRoute() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt index 560f89ca44..8548f52212 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -17,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.appbar.AppBarWithBackButton @@ -29,8 +27,6 @@ import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.models.pay.TangemPayCardFrozenState -import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayAddToWalletStepItemUM import com.tangem.features.tangempay.entity.TangemPayAddToWalletUM @@ -40,12 +36,11 @@ import kotlinx.collections.immutable.persistentListOf @Composable internal fun TangemPayAddToWalletScreen( state: TangemPayAddToWalletUM, - cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + cardDetailsState: TangemPayCardDetailsUM, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } - val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() Column( modifier = modifier @@ -66,9 +61,9 @@ internal fun TangemPayAddToWalletScreen( contentPadding = PaddingValues(bottom = TangemTheme.dimens.spacing16 + bottomBarHeight), ) { item(TangemPayCardDetailsUM::class.java) { - cardDetailsBlockComponent.CardDetailsBlockContent( - modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp), + TangemPayCard( state = cardDetailsState, + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp), ) } @@ -181,18 +176,16 @@ private fun PreviewTangemPayAddToWalletScreen() { onBackClick = {}, onClickOpenWallet = {}, ), - cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( - TangemPayCardDetailsUM( - number = "", - numberShort = "*1245", - expiry = "••/••", - cvv = "•••", - onCopy = { _, _ -> }, - onClick = {}, - buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = null, - ), + cardDetailsState = TangemPayCardDetailsUM( + number = "", + numberShort = "*1245", + expiry = "••/••", + cvv = "•••", + onCopy = { _, _ -> }, + onClick = {}, + buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = null, ), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreenV2.kt index 16e72c71f8..0c3b6175ec 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreenV2.kt @@ -9,12 +9,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton @@ -27,8 +25,6 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_cross_20 import com.tangem.domain.models.pay.TangemPayCardFrozenState -import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayAddToWalletStepItemUM import com.tangem.features.tangempay.entity.TangemPayAddToWalletUM @@ -39,11 +35,10 @@ import kotlinx.collections.immutable.persistentListOf @Composable internal fun TangemPayAddToWalletScreenV2( state: TangemPayAddToWalletUM, - cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + cardDetailsState: TangemPayCardDetailsUM, modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() - val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() Column( modifier = modifier @@ -60,11 +55,11 @@ internal fun TangemPayAddToWalletScreenV2( .verticalScroll(scrollState) .padding(top = 8.dp, bottom = 12.dp), ) { - cardDetailsBlockComponent.CardDetailsBlockContent( + TangemPayCard( + state = cardDetailsState, modifier = Modifier .padding(horizontal = 16.dp) .padding(bottom = 12.dp), - state = cardDetailsState, ) DynamicSpacer(scrollState = scrollState) AddToWalletTitle() @@ -215,18 +210,16 @@ private fun PreviewTangemPayAddToWalletScreen() { onBackClick = {}, onClickOpenWallet = {}, ), - cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( - TangemPayCardDetailsUM( - number = "", - numberShort = "*1245", - expiry = "••/••", - cvv = "•••", - onCopy = { _, _ -> }, - onClick = {}, - buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = null, - ), + cardDetailsState = TangemPayCardDetailsUM( + number = "", + numberShort = "*1245", + expiry = "••/••", + cvv = "•••", + onCopy = { _, _ -> }, + onClick = {}, + buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = null, ), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 2b9da3d7d1..56c15f7f88 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -57,9 +57,9 @@ import com.tangem.core.ui.res.* import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.CardDataType import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM -import com.tangem.features.tangempay.model.CardDataType private const val TEXT_WIDTH_PADDING = 2 private const val FREEZE_ANIMATION_DURATION_MS = 600 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 4ec8847823..be01c7a721 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 @@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text @@ -24,7 +26,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.ds.TangemPagerIndicator import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton @@ -34,12 +38,12 @@ 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 import com.tangem.features.tangempay.entity.* +import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController import com.tangem.features.tangempay.ui.components.PayContextMenuBlock import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.distinctUntilChanged import com.tangem.core.ui.R as CoreUiR private const val CONTENT_FADE_DURATION_MS = 300 @@ -47,9 +51,29 @@ private const val CONTENT_FADE_DURATION_MS = 300 @Composable internal fun TangemPayCardPageScreen( state: TangemPayCardPageUM, - cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, - cardDetailsState: TangemPayCardDetailsUM, + cardControllers: ImmutableList, + selectedCardId: String, + onCardSelect: (Int) -> Unit, modifier: Modifier = Modifier, +) { + TangemPayCardPageScreen( + state = state, + cardSection = { + TangemPayCardSwipePager( + controllers = cardControllers, + selectedCardId = selectedCardId, + onCardSelect = onCardSelect, + ) + }, + modifier = modifier, + ) +} + +@Composable +private fun TangemPayCardPageScreen( + state: TangemPayCardPageUM, + modifier: Modifier = Modifier, + cardSection: @Composable () -> Unit, ) { val isRedesignEnabled = LocalVisaRedesignEnabled.current Scaffold( @@ -80,10 +104,9 @@ internal fun TangemPayCardPageScreen( verticalArrangement = Arrangement.spacedBy(if (isRedesignEnabled) 0.dp else TangemTheme.dimens.spacing16), ) { item(key = "Card") { - cardDetailsBlockComponent.CardDetailsBlockContent( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), - state = cardDetailsState, - ) + Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) { + cardSection() + } } if (isRedesignEnabled && state.settingsV2.isNotEmpty() && state.cardState == TangemPayCardState.Active) { cardPageItem("Settings buttons") { @@ -98,6 +121,63 @@ internal fun TangemPayCardPageScreen( } } +/** + * Renders the card visual(s). With several cards they are wrapped in a [HorizontalPager] so the user + * can swipe to change the management context; the neighbouring cards peek at the screen edges and a + * dots indicator below shows the position. Each page collects its own controller's state so only the + * changed page recomposes. On settle the screen reports the new page via [onCardSelect]. + */ +@Composable +private fun TangemPayCardSwipePager( + controllers: ImmutableList, + selectedCardId: String, + onCardSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + when { + controllers.isEmpty() -> Unit + controllers.size == 1 -> CardDetailsPage(controller = controllers.first(), modifier = modifier) + else -> { + val initialPage = controllers.indexOfFirst { it.cardId == selectedCardId }.coerceAtLeast(0) + val pagerState = rememberPagerState(initialPage = initialPage) { controllers.size } + + LaunchedEffect(pagerState, controllers) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect(onCardSelect) + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + // Side padding keeps the current card centered while the neighbours peek at the edges. + contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing32), + pageSpacing = TangemTheme.dimens.spacing8, + beyondViewportPageCount = 1, + key = { controllers[it].cardId }, + ) { page -> + CardDetailsPage(controller = controllers[page]) + } + + TangemPagerIndicator( + pagerState = pagerState, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + ) + } + } + } +} + +@Composable +private fun CardDetailsPage(controller: TangemPayCardDetailsController, modifier: Modifier = Modifier) { + val cardDetailsState by controller.uiState.collectAsStateWithLifecycle() + TangemPayCard(state = cardDetailsState, modifier = modifier) +} + private fun LazyListScope.cardState(state: TangemPayCardPageUM) { when (state.cardState) { TangemPayCardState.Active -> { @@ -254,40 +334,26 @@ private fun TangemPayCardPageScreenPreviewV1() { TangemThemePreview { TangemPayCardPageScreen( state = TangemPayCardPageUM.stub(), - cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( - TangemPayCardDetailsUM( - number = "•••• •••• •••• 1245", - numberShort = "··1245", - expiry = "••/••", - cvv = "•••", - onCopy = { _, _ -> }, - onClick = {}, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = DisplayNameState.Display( - displayName = "Tangem Pay Card", - onClick = {}, - isEditingEnabled = true, - ), - ), - ), - cardDetailsState = TangemPayCardDetailsUM( - number = "•••• •••• •••• 1245", - numberShort = "··1245", - expiry = "••/••", - cvv = "•••", - onCopy = { _, _ -> }, - onClick = {}, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = DisplayNameState.Display( - displayName = "Tangem Pay Card", - onClick = {}, - isEditingEnabled = false, - ), - ), + cardSection = { TangemPayCard(state = previewCardDetailsState()) }, ) } } +private fun previewCardDetailsState(): TangemPayCardDetailsUM = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + numberShort = "··1245", + expiry = "••/••", + cvv = "•••", + onCopy = { _, _ -> }, + onClick = {}, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display( + displayName = "Tangem Pay Card", + onClick = {}, + isEditingEnabled = false, + ), +) + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -299,36 +365,7 @@ private fun TangemPayCardPageScreenPreviewV2() { ) { TangemPayCardPageScreen( state = TangemPayCardPageUM.stub(), - cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( - TangemPayCardDetailsUM( - number = "•••• •••• •••• 1245", - numberShort = "··1245", - expiry = "••/••", - cvv = "•••", - onCopy = { _, _ -> }, - onClick = {}, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = DisplayNameState.Display( - displayName = "Tangem Pay Card", - onClick = {}, - isEditingEnabled = true, - ), - ), - ), - cardDetailsState = TangemPayCardDetailsUM( - number = "•••• •••• •••• 1245", - numberShort = "··1245", - expiry = "••/••", - cvv = "•••", - onCopy = { _, _ -> }, - onClick = {}, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = DisplayNameState.Display( - displayName = "Tangem Pay Card", - onClick = {}, - isEditingEnabled = false, - ), - ), + cardSection = { TangemPayCard(state = previewCardDetailsState()) }, ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt index 817eaffea4..652b53d9f0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt @@ -19,7 +19,6 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_cross_20 -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM @@ -28,21 +27,18 @@ import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM internal fun TangemPayEditDisplayNameScreen( isRedesignEnabled: Boolean, state: TangemPayEditDisplayNameUM, - cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, cardDetailsState: TangemPayCardDetailsUM, modifier: Modifier = Modifier, ) { if (isRedesignEnabled) { TangemPayEditDisplayNameScreenV2( state = state, - cardDetailsBlockComponent = cardDetailsBlockComponent, cardDetailsState = cardDetailsState, modifier = modifier, ) } else { TangemPayEditDisplayNameScreenV1( state = state, - cardDetailsBlockComponent = cardDetailsBlockComponent, cardDetailsState = cardDetailsState, modifier = modifier, ) @@ -52,7 +48,6 @@ internal fun TangemPayEditDisplayNameScreen( @Composable internal fun TangemPayEditDisplayNameScreenV1( state: TangemPayEditDisplayNameUM, - cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, cardDetailsState: TangemPayCardDetailsUM, modifier: Modifier = Modifier, ) { @@ -80,11 +75,11 @@ internal fun TangemPayEditDisplayNameScreenV1( } } - cardDetailsBlockComponent.CardDetailsBlockContent( + TangemPayCard( + state = cardDetailsState, modifier = Modifier .padding(horizontal = 16.dp) .padding(top = 8.dp), - state = cardDetailsState, ) Spacer(modifier = Modifier.weight(1f)) @@ -105,7 +100,6 @@ internal fun TangemPayEditDisplayNameScreenV1( @Composable internal fun TangemPayEditDisplayNameScreenV2( state: TangemPayEditDisplayNameUM, - cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, cardDetailsState: TangemPayCardDetailsUM, modifier: Modifier = Modifier, ) { @@ -133,9 +127,9 @@ internal fun TangemPayEditDisplayNameScreenV2( .fillMaxWidth() .verticalScroll(rememberScrollState()), ) { - cardDetailsBlockComponent.CardDetailsBlockContent( - modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2), + TangemPayCard( state = cardDetailsState, + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayIssueAdditionalCardContent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayIssueAdditionalCardContent.kt new file mode 100644 index 0000000000..118bd6d6e8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayIssueAdditionalCardContent.kt @@ -0,0 +1,211 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +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.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButtonIconEnd +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayIssueAdditionalCardUM + +@Composable +internal fun TangemPayIssueAdditionalCardContent(state: TangemPayIssueAdditionalCardUM) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = state.onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + onBack = state.onDismiss, + containerColor = TangemTheme.colors.background.secondary, + title = { + TangemModalBottomSheetTitle( + endIconRes = R.drawable.ic_close_24, + onEndClick = state.onDismiss, + ) + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CardIcon() + Text( + modifier = Modifier.padding(top = 16.dp), + text = stringResourceSafe(R.string.tangempay_issue_additional_card_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = stringResourceSafe(R.string.tangempay_issue_additional_card_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + FeeRow(modifier = Modifier.padding(top = 16.dp), state = state) + if (state.isBalanceInsufficient) { + InsufficientFundsBlock(modifier = Modifier.padding(top = 8.dp), onAddFundsClick = state.onAddFundsClick) + } + PrimaryButton( + modifier = Modifier + .padding(vertical = 16.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.tangempay_issue_card), + showProgress = state.isLoading, + enabled = !state.isLoading && !state.isBalanceInsufficient, + onClick = state.onIssueClick, + ) + } + } +} + +@Composable +private fun CardIcon() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = CircleShape, + ) + .size(48.dp), + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_credit_card_add_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } +} + +@Composable +private fun FeeRow(state: TangemPayIssueAdditionalCardUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.action) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(R.string.tangempay_issue_additional_card_fee_label), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = state.feeText, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Composable +private fun InsufficientFundsBlock(onAddFundsClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.action) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Image( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.img_usdc_16), + contentDescription = null, + ) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text( + text = stringResourceSafe(R.string.tangempay_reissue_card_insufficient_funds_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = stringResourceSafe( + R.string.tangempay_reissue_card_insufficient_funds_subtitle, + ), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + SecondaryButtonIconEnd( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + text = stringResourceSafe(R.string.common_add_funds), + iconResId = R.drawable.ic_plus_24, + onClick = onAddFundsClick, + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemPayIssueAdditionalCardContentPreview( + @PreviewParameter(IssueAdditionalCardPreviewProvider::class) state: TangemPayIssueAdditionalCardUM, +) { + TangemThemePreview { + TangemPayIssueAdditionalCardContent(state = state) + } +} + +private class IssueAdditionalCardPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TangemPayIssueAdditionalCardUM( + isBalanceInsufficient = false, + feeText = "10 $", + isLoading = false, + onIssueClick = {}, + onAddFundsClick = {}, + onDismiss = {}, + ), + TangemPayIssueAdditionalCardUM( + isBalanceInsufficient = true, + feeText = "10 $", + isLoading = false, + onIssueClick = {}, + onAddFundsClick = {}, + onDismiss = {}, + ), + TangemPayIssueAdditionalCardUM( + isBalanceInsufficient = false, + feeText = "10 $", + isLoading = true, + onIssueClick = {}, + onAddFundsClick = {}, + onDismiss = {}, + ), + ) +} \ 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 7d68426983..b1f9098f80 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 @@ -9,7 +9,7 @@ internal interface TangemPayDetailIntents { fun onClickAddFunds() fun onClickWithdraw() fun onClickTermsAndLimits() - fun onCardClick() + fun onCardClick(cardId: String) fun onAddCardClick() fun onRemoveAccount() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt index 6802522a42..dcb2a6e6c0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt @@ -4,7 +4,9 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.message.* import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.BottomSheetMessage +import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_snowflake_20 @@ -147,6 +149,32 @@ internal object TangemPayMessagesFactory { } } + fun createMaximumCardsIssued(maxCards: Int): BottomSheetMessage { + return bottomSheetMessage { + infoBlock { + icon(R.drawable.img_attention_20) { + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention + } + title = resourceReference(R.string.tangempay_maximum_cards_issued_title) + body = resourceReference( + id = R.string.tangempay_maximum_cards_issued_description, + formatArgs = wrappedList(maxCards), + ) + } + primaryButton { + text = resourceReference(R.string.common_got_it) + onClick { closeBs() } + } + } + } + + fun createGenericError(): DialogMessage { + return DialogMessage( + title = resourceReference(R.string.common_something_went_wrong), + message = resourceReference(R.string.common_try_again_later), + ) + } + fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage { return bottomSheetMessage { infoBlock { diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsControllerTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsControllerTest.kt new file mode 100644 index 0000000000..7bb138d4e0 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsControllerTest.kt @@ -0,0 +1,257 @@ +package com.tangem.features.tangempay.model.controller + +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.error.UniversalError +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.features.tangempay.entity.CardDataType +import com.tangem.features.tangempay.model.listener.CardDetailsEvent +import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener +import com.tangem.utils.StringsSigns +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +private const val SHOW_DETAILS_TIME = 30_000L + +internal class TangemPayCardDetailsControllerTest { + + private val userWalletId = UserWalletId("123") + private val cardId = "card_1" + private val otherCardId = "card_2" + + private val repository: TangemPayCardDetailsRepository = mockk(relaxed = true) + private val clipboardManager: ClipboardManager = mockk(relaxed = true) + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val analytics: AnalyticsEventHandler = mockk(relaxed = true) + private val supplier: PaymentAccountStatusSupplier = mockk() + private val supplierFlow = MutableSharedFlow(replay = 1) + private val eventListener = DefaultCardDetailsEventListener() + + private val details = TangemPayCardDetails( + pan = "4242424242424242", + cvv = "123", + expirationYear = "2030", + expirationMonth = "9", + ) + + init { + every { supplier.invoke(userWalletId) } returns supplierFlow + every { repository.cardFrozenState(any()) } returns emptyFlow() + } + + @Test + fun `GIVEN card WHEN created THEN initial state is hidden with masked digits`() = runTest { + val controller = createController(scope = backgroundScope, card = card(lastDigits = "9999")) + runCurrent() + + val state = controller.uiState.value + assertThat(state.isHidden).isTrue() + assertThat(state.numberShort).isEqualTo("${StringsSigns.ASTERISK}9999") + } + + @Test + fun `GIVEN Show for this card WHEN event received THEN details revealed and analytics sent`() = runTest { + coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right() + val controller = createController(scope = backgroundScope) + runCurrent() + + eventListener.send(CardDetailsEvent.Show(cardId)) + runCurrent() + + verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.ViewCardDetailsClicked()) } + coVerify(exactly = 1) { repository.revealCardDetails(userWalletId, cardId) } + val state = controller.uiState.value + assertThat(state.isHidden).isFalse() + assertThat(state.cvv).isEqualTo("123") + assertThat(state.expiry).isEqualTo("09/30") + } + + @Test + fun `GIVEN reveal fails WHEN event received THEN snackbar shown and state hidden`() = runTest { + coEvery { repository.revealCardDetails(userWalletId, cardId) } returns ERROR.left() + val controller = createController(scope = backgroundScope) + runCurrent() + + eventListener.send(CardDetailsEvent.Show(cardId)) + runCurrent() + + verify(exactly = 1) { uiMessageSender.send(any()) } + assertThat(controller.uiState.value.isHidden).isTrue() + } + + @Test + fun `GIVEN revealed WHEN Show for another card THEN this card hides`() = runTest { + coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right() + val controller = createController(scope = backgroundScope) + runCurrent() + eventListener.send(CardDetailsEvent.Show(cardId)) + runCurrent() + + eventListener.send(CardDetailsEvent.Show(otherCardId)) + runCurrent() + + assertThat(controller.uiState.value.isHidden).isTrue() + } + + @Test + fun `GIVEN revealed WHEN Hide for another card THEN this card stays revealed`() = runTest { + coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right() + val controller = createController(scope = backgroundScope) + runCurrent() + eventListener.send(CardDetailsEvent.Show(cardId)) + runCurrent() + + eventListener.send(CardDetailsEvent.Hide(otherCardId)) + runCurrent() + + assertThat(controller.uiState.value.isHidden).isFalse() + } + + @Test + fun `GIVEN revealed WHEN HideAll THEN this card hides`() = runTest { + coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right() + val controller = createController(scope = backgroundScope) + runCurrent() + eventListener.send(CardDetailsEvent.Show(cardId)) + runCurrent() + + eventListener.send(CardDetailsEvent.HideAll) + runCurrent() + + assertThat(controller.uiState.value.isHidden).isTrue() + } + + @Test + fun `GIVEN revealed WHEN show timer elapses THEN details auto-hide`() = runTest { + coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right() + val controller = createController(scope = backgroundScope) + runCurrent() + eventListener.send(CardDetailsEvent.Show(cardId)) + runCurrent() + + advanceTimeBy(SHOW_DETAILS_TIME + 1) + runCurrent() + + assertThat(controller.uiState.value.isHidden).isTrue() + } + + @Test + fun `GIVEN supplier emits actual loaded WHEN card present THEN frozen state and digits updated`() = runTest { + val updatedCard = card(lastDigits = "5678", frozenState = TangemPayCardFrozenState.Frozen) + val loaded = mockk { + every { source } returns StatusSource.ACTUAL + every { cards } returns listOf(updatedCard) + } + val payment = mockk { + every { value } returns loaded + } + val controller = createController(scope = backgroundScope) + runCurrent() + + supplierFlow.emit(payment) + runCurrent() + + val state = controller.uiState.value + assertThat(state.numberShort).isEqualTo("${StringsSigns.ASTERISK}5678") + assertThat(state.cardFrozenState).isEqualTo(TangemPayCardFrozenState.Frozen) + assertThat(state.isActionsAvailable).isTrue() + } + + @Test + fun `GIVEN copy cvv WHEN onCopy invoked THEN clipboard set as sensitive and analytics sent`() = runTest { + val controller = createController(scope = backgroundScope) + runCurrent() + + controller.uiState.value.onCopy("1 2 3", CardDataType.CVV) + + verify(exactly = 1) { clipboardManager.setText(text = "123", isSensitive = true) } + verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.CopyCardCVVClicked()) } + } + + @Test + fun `GIVEN disposed WHEN Show received THEN reveal not triggered`() = runTest { + coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right() + val controller = createController(scope = backgroundScope) + runCurrent() + + controller.dispose() + eventListener.send(CardDetailsEvent.Show(cardId)) + advanceUntilIdle() + + coVerify(exactly = 0) { repository.revealCardDetails(any(), any()) } + } + + private fun TestScope.createController( + scope: CoroutineScope, + card: TangemPayCard = card(), + config: TangemPayCardDetailsController.Config = TangemPayCardDetailsController.Config( + isEditingNameEnabled = true, + shouldShowCardDetailsButtonOnCard = false, + ), + onEditNameClick: () -> Unit = {}, + ): TangemPayCardDetailsController = TangemPayCardDetailsController( + scope = scope, + initialCard = card, + userWalletId = userWalletId, + config = config, + onEditNameClick = onEditNameClick, + cardDetailsRepository = repository, + clipboardManager = clipboardManager, + uiMessageSender = uiMessageSender, + cardDetailsEventListener = eventListener, + analytics = analytics, + paymentAccountStatusSupplier = supplier, + ) + + private fun card( + id: String = cardId, + lastDigits: String = "1234", + displayName: CardDisplayName? = null, + frozenState: TangemPayCardFrozenState = TangemPayCardFrozenState.Unfrozen, + state: TangemPayCardState = TangemPayCardState.Active, + ): TangemPayCard = TangemPayCard( + id = id, + productInstanceId = "product_$id", + cardStatus = TangemPayCard.Status.ACTIVE, + hasPinCode = true, + displayName = displayName, + limit = null, + frozenState = frozenState, + lastDigits = lastDigits, + state = state, + ) + + private companion object { + val ERROR = object : UniversalError { + override val errorCode: Int = 0 + } + } +} \ No newline at end of file