Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-20 14:05:32 +05:00
parent 9aa3bf7541
commit 3ea0fbb8a3
32 changed files with 618 additions and 421 deletions

View file

@ -0,0 +1,23 @@
package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object TangemPayStoresModule {
@Provides
@Singleton
fun provideTangemPayCardFrozenStateStore(): TangemPayCardFrozenStateStore {
return DefaultTangemPayCardFrozenStateStore(
dataStore = RuntimeDataStore(),
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
internal class DefaultTangemPayCardFrozenStateStore(
private val dataStore: StringKeyDataStore<TangemPayCardFrozenState>,
) : TangemPayCardFrozenStateStore {
override suspend fun getSyncOrNull(key: String): TangemPayCardFrozenState? {
return dataStore.getSyncOrNull(key)
}
override fun get(key: String): Flow<TangemPayCardFrozenState> {
return dataStore.get(key)
}
override suspend fun store(key: String, value: TangemPayCardFrozenState) {
dataStore.store(key, value)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
interface TangemPayCardFrozenStateStore {
suspend fun getSyncOrNull(key: String): TangemPayCardFrozenState?
fun get(key: String): Flow<TangemPayCardFrozenState>
suspend fun store(key: String, value: TangemPayCardFrozenState)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -14,6 +14,7 @@ object StringsSigns {
const val NON_BREAKING_SPACE = '\u00A0'
const val PERCENT = "%"
const val THREE_STARS = "\u2217\u2217\u2217"
const val ASTERISK = "\u2217"
const val PASSWORD_VISUAL_CHAR = '\u2022'
const val APPROXIMATE = ""
const val WHITE_SPACE = " "

View file

@ -8,6 +8,7 @@ import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
@ -16,6 +17,7 @@ import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ -24,6 +26,7 @@ private const val VALID_STATUS = "valid"
private const val APPROVED_KYC_STATUS = "APPROVED"
private const val TAG = "TangemPay: OnboardingRepository"
@Suppress("LongParameterList")
internal class DefaultOnboardingRepository @Inject constructor(
private val dispatcherProvider: CoroutineDispatcherProvider,
private val tangemPayApi: TangemPayApi,
@ -31,6 +34,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
private val tangemPayStorage: TangemPayStorage,
private val authDataSource: TangemPayAuthDataSource,
private val tangemPayWalletsManager: TangemPayWalletsManager,
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
) : OnboardingRepository {
override suspend fun validateDeeplink(link: String): Either<UniversalError, Boolean> {
@ -122,7 +126,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
}
}
private fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo {
private suspend fun getCustomerInfo(response: CustomerMeResponse.Result?): CustomerInfo {
val card = response?.card
val balance = response?.balance
val paymentAccount = response?.paymentAccount
@ -138,6 +142,13 @@ internal class DefaultOnboardingRepository @Inject constructor(
null
}
val productInstance = response?.productInstance?.let { instance ->
cardFrozenStateStore.store(
key = instance.cardId,
value = when (instance.status) {
CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
},
)
ProductInstance(
id = instance.id,
cardId = instance.cardId,

View file

@ -12,18 +12,19 @@ import com.tangem.datasource.api.pay.models.request.CardDetailsRequest
import com.tangem.datasource.api.pay.models.request.FreezeUnfreezeCardRequest
import com.tangem.datasource.api.pay.models.request.SetPinRequest
import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.visa.error.VisaApiError
import timber.log.Timber
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
private const val TAG = "TangemPay: CardDetailsRepository"
@Suppress("OptionalUnit")
@Suppress("LongParameterList")
internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
@ -31,6 +32,7 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
private val apiConfigsManager: ApiConfigsManager,
private val rainCryptoUtil: RainCryptoUtil,
private val storage: TangemPayStorage,
private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
) : TangemPayCardDetailsRepository {
override suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance> {
@ -117,34 +119,58 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
}
}
override suspend fun freezeCard(cardId: String): Either<UniversalError, Unit> {
override suspend fun freezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState> {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending)
return requestHelper.makeSafeRequest {
tangemPayApi.freezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId))
}.onLeft {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Unfrozen)
}.map { response ->
val result = response.result
if (result != null && result.status == FreezeUnfreezeCardResponse.Status.COMPLETED) {
Either.Right(Unit)
} else {
Timber.e("Card freeze failed with status: ${response.result?.status}")
Either.Left(VisaApiError.UnknownWithoutCode)
val state = when (response.result?.status) {
FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Frozen
FreezeUnfreezeCardResponse.Status.NEW,
FreezeUnfreezeCardResponse.Status.PROCESSING,
-> TangemPayCardFrozenState.Pending
FreezeUnfreezeCardResponse.Status.CANCELED,
null,
-> TangemPayCardFrozenState.Unfrozen
}
cardFrozenStateStore.store(cardId, state)
state
}
}
override suspend fun unfreezeCard(cardId: String): Either<UniversalError, Unit> {
override suspend fun unfreezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState> {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending)
return requestHelper.makeSafeRequest {
tangemPayApi.unfreezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId))
}.onLeft {
cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Frozen)
}.map { response ->
val result = response.result
if (result != null && result.status == FreezeUnfreezeCardResponse.Status.COMPLETED) {
Either.Right(Unit)
} else {
Timber.e("Card unfreeze failed with status: ${response.result?.status}")
Either.Left(VisaApiError.UnknownWithoutCode)
val state = when (response.result?.status) {
FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Unfrozen
FreezeUnfreezeCardResponse.Status.NEW,
FreezeUnfreezeCardResponse.Status.PROCESSING,
-> TangemPayCardFrozenState.Pending
FreezeUnfreezeCardResponse.Status.CANCELED,
null,
-> TangemPayCardFrozenState.Frozen
}
cardFrozenStateStore.store(cardId, state)
state
}
}
override fun cardFrozenState(cardId: String): Flow<TangemPayCardFrozenState> {
return cardFrozenStateStore.get(cardId)
}
override suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? {
return cardFrozenStateStore.getSyncOrNull(cardId)
}
private suspend fun getPublicKeyBase64(): String {
val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment
return when (env) {

View file

@ -1,11 +1,12 @@
package com.tangem.domain.pay
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.serialization.Serializable
@Serializable
data class TangemPayDetailsConfig(
val cardId: String,
val isCardFrozen: Boolean,
val cardFrozenState: TangemPayCardFrozenState,
val customerWalletAddress: String,
val cardNumberEnd: String,
val chainId: Int,

View file

@ -0,0 +1,10 @@
package com.tangem.domain.visa.model
import kotlinx.serialization.Serializable
@Serializable
sealed class TangemPayCardFrozenState {
data object Pending : TangemPayCardFrozenState()
data object Frozen : TangemPayCardFrozenState()
data object Unfrozen : TangemPayCardFrozenState()
}

View file

@ -5,6 +5,8 @@ import com.tangem.core.error.UniversalError
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
interface TangemPayCardDetailsRepository {
@ -18,6 +20,8 @@ interface TangemPayCardDetailsRepository {
suspend fun setAddToWalletAsDone(): Either<UniversalError, Unit>
suspend fun freezeCard(cardId: String): Either<UniversalError, Unit>
suspend fun unfreezeCard(cardId: String): Either<UniversalError, Unit>
suspend fun freezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState>
suspend fun unfreezeCard(cardId: String): Either<UniversalError, TangemPayCardFrozenState>
fun cardFrozenState(cardId: String): Flow<TangemPayCardFrozenState>
suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState?
}

View file

@ -6,7 +6,7 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel
import com.tangem.features.tangempay.ui.CardDetailsItem
import com.tangem.features.tangempay.ui.TangemPayCard
import kotlinx.coroutines.flow.StateFlow
internal class DefaultTangemPayCardDetailsBlockComponent(
@ -19,6 +19,6 @@ internal class DefaultTangemPayCardDetailsBlockComponent(
@Composable
override fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) {
CardDetailsItem(state, modifier)
TangemPayCard(state, modifier)
}
}

View file

@ -3,7 +3,7 @@ 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.CardDetailsItem
import com.tangem.features.tangempay.ui.TangemPayCard
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -15,6 +15,6 @@ internal class PreviewTangemPayCardDetailsBlockComponent(
@Composable
override fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) {
CardDetailsItem(state, modifier)
TangemPayCard(state, modifier)
}
}

View file

@ -1,11 +1,13 @@
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.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import kotlinx.coroutines.flow.StateFlow
@Stable
internal interface TangemPayCardDetailsBlockComponent {
val state: StateFlow<TangemPayCardDetailsUM>

View file

@ -1,33 +1,24 @@
package com.tangem.features.tangempay.entity
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.utils.CardDetailsFormatUtil
import com.tangem.utils.StringsSigns
private const val CARD_NUMBER_SART_DIGITS_COUNT = 12
private const val DATE_PART_LENGTH = 2
private const val CVV_LENGTH = 3
internal class TangemPayCardDetailsBlockStateFactory(
private val cardNumberEnd: String,
private val onReveal: () -> Unit,
private val onCopy: (String) -> Unit,
) {
private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT)
private val dateMasked = maskedBlock(DATE_PART_LENGTH)
private val cvvMasked = maskedBlock(CVV_LENGTH)
fun getInitialState() = TangemPayCardDetailsUM(
number = CardDetailsFormatUtil.formatCardNumber(cardNumber = "$cardStartMasked$cardNumberEnd"),
expiry = CardDetailsFormatUtil.formatDate(month = dateMasked, year = dateMasked),
cvv = cvvMasked,
buttonText = TextReference.Res(R.string.tangempay_card_details_reveal_text),
number = "${StringsSigns.ASTERISK}$cardNumberEnd",
expiry = "",
cvv = "",
buttonText = resourceReference(R.string.tangempay_card_details_reveal_text),
onClick = onReveal,
onCopy = onCopy,
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
)
private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count)
}

View file

@ -6,20 +6,25 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.model.transformers.TangemPayCardFrozenStateConverter
import com.tangem.features.tangempay.utils.TangemPayDetailIntents
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Suppress("LongParameterList")
internal class TangemPayDetailsStateFactory(
private val onBack: () -> Unit,
private val intents: TangemPayDetailIntents,
private val isCardFrozen: Boolean,
private val cardFrozenState: TangemPayCardFrozenState,
private val converter: TangemPayCardFrozenStateConverter,
) {
fun getInitialState(): TangemPayDetailsUM {
val cardFrozenStateItem = if (isCardFrozen) {
TangemPayDetailsTopBarMenuItem(
val cardFrozenStateItem = when (cardFrozenState) {
is TangemPayCardFrozenState.Pending -> null
is TangemPayCardFrozenState.Frozen -> TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.UnfreezeCard,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_card_details_unfreeze_card),
@ -27,8 +32,7 @@ internal class TangemPayDetailsStateFactory(
onClick = intents::onClickUnfreezeCard,
),
)
} else {
TangemPayDetailsTopBarMenuItem(
is TangemPayCardFrozenState.Unfrozen -> TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.FreezeCard,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_card_details_freeze_card),
@ -41,7 +45,7 @@ internal class TangemPayDetailsStateFactory(
return TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = onBack,
items = persistentListOf(
items = listOfNotNull(
TangemPayDetailsTopBarMenuItem(
type = TangemPayDetailsTopBarMenuItemType.ChangePin,
dropdownItem = TangemDropdownMenuItem(
@ -59,7 +63,7 @@ internal class TangemPayDetailsStateFactory(
),
),
cardFrozenStateItem,
),
).toPersistentList(),
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
@ -73,15 +77,11 @@ internal class TangemPayDetailsStateFactory(
onClick = intents::onClickAddFunds,
),
),
frozenState = if (isCardFrozen) {
CardFrozenState.Frozen(intents::onClickUnfreezeCard)
} else {
CardFrozenState.Unfrozen
},
),
addToWalletBlockState = null,
isBalanceHidden = false,
addFundsEnabled = true,
cardFrozenState = converter.convert(cardFrozenState),
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.tangempay.entity
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.collections.immutable.ImmutableList
internal data class TangemPayDetailsUM(
@ -12,6 +13,7 @@ internal data class TangemPayDetailsUM(
val addToWalletBlockState: AddToWalletBlockState?,
val isBalanceHidden: Boolean,
val addFundsEnabled: Boolean,
val cardFrozenState: CardFrozenState,
)
internal data class TangemPayCardDetailsUM(
@ -23,21 +25,19 @@ internal data class TangemPayCardDetailsUM(
val onCopy: (String) -> Unit = {},
val isHidden: Boolean = true,
val isLoading: Boolean = false,
val cardFrozenState: TangemPayCardFrozenState,
)
internal sealed class TangemPayDetailsBalanceBlockState {
abstract val actionButtons: ImmutableList<ActionButtonConfig>
abstract val frozenState: CardFrozenState
data class Loading(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val frozenState: CardFrozenState,
) : TangemPayDetailsBalanceBlockState()
data class Content(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val frozenState: CardFrozenState,
val cryptoBalance: String,
val fiatBalance: String,
val isBalanceFlickering: Boolean,
@ -45,7 +45,6 @@ internal sealed class TangemPayDetailsBalanceBlockState {
data class Error(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val frozenState: CardFrozenState,
) : TangemPayDetailsBalanceBlockState()
}

View file

@ -21,12 +21,10 @@ import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTran
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import com.tangem.utils.transformer.update as transformerUpdate
@Stable
@ModelScoped
@ -53,6 +51,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
private val revealCardDetailsJobHolder = JobHolder()
init {
subscribeToCardFrozenState()
modelScope.launch {
cardDetailsEventListener.event.collectLatest { event ->
when (event) {
@ -63,22 +62,29 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
}
}
private fun subscribeToCardFrozenState() {
cardDetailsRepository
.cardFrozenState(params.params.config.cardId)
.onEach { uiState.update { state -> state.copy(cardFrozenState = it) } }
.launchIn(modelScope)
}
private fun revealCardDetails() {
modelScope.launch {
uiState.update(
uiState.transformerUpdate(
transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails),
)
cardDetailsRepository.revealCardDetails()
.onRight {
uiState.update(
.onRight { cardDetails ->
uiState.transformerUpdate(
transformer = DetailsRevealedStateTransformer(
details = it,
details = cardDetails,
onClickHide = ::hideCardDetails,
),
)
}
.onLeft {
uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory))
uiState.transformerUpdate(transformer = DetailsHiddenStateTransformer(stateFactory))
showError()
}
}.saveIn(revealCardDetailsJobHolder)
@ -87,7 +93,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
fun hideCardDetails() {
modelScope.launch {
revealCardDetailsJobHolder.cancel()
uiState.update(transformer = DetailsHiddenStateTransformer(stateFactory))
uiState.transformerUpdate(transformer = DetailsHiddenStateTransformer(stateFactory))
}
}

View file

@ -20,6 +20,7 @@ import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.pay.TangemPayTopUpData
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.components.AddFundsListener
@ -42,8 +43,10 @@ import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -64,10 +67,12 @@ internal class TangemPayDetailsModel @Inject constructor(
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
private val cardFrozenStateConverter = TangemPayCardFrozenStateConverter(onUnfreezeClick = ::onClickUnfreezeCard)
private val stateFactory = TangemPayDetailsStateFactory(
onBack = router::pop,
intents = this,
isCardFrozen = params.config.isCardFrozen,
cardFrozenState = params.config.cardFrozenState,
converter = cardFrozenStateConverter,
)
val uiState: StateFlow<TangemPayDetailsUM>
@ -85,6 +90,23 @@ internal class TangemPayDetailsModel @Inject constructor(
handleBalanceHiding()
fetchAddToWalletBanner()
fetchBalance()
subscribeToCardFrozenState()
}
private fun subscribeToCardFrozenState() {
cardDetailsRepository
.cardFrozenState(params.config.cardId)
.onEach { state ->
uiState.update(
TangemPayFreezeUnfreezeStateTransformer(
cardFrozenState = state,
onFreezeClick = ::onClickFreezeCard,
onUnfreezeClick = ::onClickUnfreezeCard,
converter = cardFrozenStateConverter,
),
)
}
.launchIn(modelScope)
}
override fun onClickChangePin() {
@ -101,54 +123,64 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun freezeCard() {
modelScope.launch {
uiState.update(TangemPayFreezeUnfreezeProcessTransformer)
cardDetailsRepository.freezeCard(cardId = params.config.cardId)
.onLeft {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)))
uiState.update(
TangemPayFreezeUnfreezeStateTransformer(
frozen = false,
onFreezeClick = ::onClickFreezeCard,
onUnfreezeClick = ::onClickUnfreezeCard,
),
)
}
.onRight {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)))
uiState.update(
TangemPayFreezeUnfreezeStateTransformer(
frozen = true,
onFreezeClick = ::onClickFreezeCard,
onUnfreezeClick = ::onClickUnfreezeCard,
),
)
.onRight { state ->
when (state) {
TangemPayCardFrozenState.Frozen -> {
uiMessageSender.send(
SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)),
)
uiState.update(
TangemPayFreezeUnfreezeStateTransformer(
cardFrozenState = state,
onFreezeClick = ::onClickFreezeCard,
onUnfreezeClick = ::onClickUnfreezeCard,
converter = cardFrozenStateConverter,
),
)
}
TangemPayCardFrozenState.Unfrozen -> {
uiMessageSender.send(
SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)),
)
}
TangemPayCardFrozenState.Pending -> Unit // TODO [REDACTED_JIRA]
}
}
}
}
private fun unfreezeCard() {
modelScope.launch {
uiState.update(TangemPayFreezeUnfreezeProcessTransformer)
cardDetailsRepository.unfreezeCard(cardId = params.config.cardId)
.onLeft {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)))
uiState.update(
TangemPayFreezeUnfreezeStateTransformer(
frozen = true,
onFreezeClick = ::onClickFreezeCard,
onUnfreezeClick = ::onClickUnfreezeCard,
),
)
}
.onRight {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)))
uiState.update(
TangemPayFreezeUnfreezeStateTransformer(
frozen = false,
onFreezeClick = ::onClickFreezeCard,
onUnfreezeClick = ::onClickUnfreezeCard,
),
)
.onRight { state ->
when (state) {
TangemPayCardFrozenState.Unfrozen -> {
uiMessageSender.send(
SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)),
)
uiState.update(
TangemPayFreezeUnfreezeStateTransformer(
cardFrozenState = state,
onFreezeClick = ::onClickFreezeCard,
onUnfreezeClick = ::onClickUnfreezeCard,
converter = cardFrozenStateConverter,
),
)
}
TangemPayCardFrozenState.Frozen -> {
uiMessageSender.send(
SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)),
)
}
TangemPayCardFrozenState.Pending -> Unit // TODO [REDACTED_JIRA]
}
}
}
}

View file

@ -18,10 +18,7 @@ internal class DetailsBalanceTransformer(
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
val balance = when (balance) {
is Either.Left<UniversalError> -> {
TangemPayDetailsBalanceBlockState.Error(
actionButtons = persistentListOf(),
frozenState = prevState.balanceBlockState.frozenState,
)
TangemPayDetailsBalanceBlockState.Error(actionButtons = persistentListOf())
}
is Either.Right<TangemPayCardBalance> -> {
TangemPayDetailsBalanceBlockState.Content(
@ -30,7 +27,6 @@ internal class DetailsBalanceTransformer(
// TODO [REDACTED_TASK_KEY]: Add crypto balance when the BFF is ready
cryptoBalance = "",
actionButtons = prevState.balanceBlockState.actionButtons,
frozenState = prevState.balanceBlockState.frozenState,
)
}
}

View file

@ -1,26 +1,39 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.utils.CardDetailsFormatUtil
import com.tangem.utils.transformer.Transformer
private const val CARD_NUMBER_CHUNK_LENGTH = 4
private const val DATE_LENGTH = 2
internal class DetailsRevealedStateTransformer(
private val details: TangemPayCardDetails,
private val onClickHide: (() -> Unit),
) : Transformer<TangemPayCardDetailsUM> {
override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM {
return TangemPayCardDetailsUM(
number = CardDetailsFormatUtil.formatCardNumber(cardNumber = details.pan),
expiry = CardDetailsFormatUtil.formatDate(month = details.expirationMonth, year = details.expirationYear),
return prevState.copy(
number = formatCardNumber(cardNumber = details.pan),
expiry = formatDate(month = details.expirationMonth, year = details.expirationYear),
cvv = details.cvv,
onClick = onClickHide,
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
onCopy = prevState.onCopy,
buttonText = resourceReference(R.string.tangempay_card_details_hide_text),
isHidden = false,
)
}
private fun formatCardNumber(cardNumber: String): String {
return cardNumber
.filterNot { it.isWhitespace() }
.chunked(CARD_NUMBER_CHUNK_LENGTH).joinToString(" ")
}
private fun formatDate(month: String, year: String): String {
val monthFormatted = month.padStart(DATE_LENGTH, '0')
val yearFormatted = year.takeLast(DATE_LENGTH)
return "$monthFormatted/$yearFormatted"
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.entity.CardFrozenState
import com.tangem.utils.converter.Converter
internal class TangemPayCardFrozenStateConverter(
private val onUnfreezeClick: () -> Unit,
) : Converter<TangemPayCardFrozenState, CardFrozenState> {
override fun convert(value: TangemPayCardFrozenState): CardFrozenState {
return when (value) {
TangemPayCardFrozenState.Unfrozen -> CardFrozenState.Unfrozen
TangemPayCardFrozenState.Frozen -> CardFrozenState.Frozen(onUnfreezeClick)
TangemPayCardFrozenState.Pending -> CardFrozenState.Pending
}
}
}

View file

@ -1,21 +0,0 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.features.tangempay.entity.CardFrozenState
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.utils.transformer.Transformer
internal object TangemPayFreezeUnfreezeProcessTransformer : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
val newBalanceBlockState = when (val balanceState = prevState.balanceBlockState) {
is TangemPayDetailsBalanceBlockState.Error -> balanceState.copy(frozenState = CardFrozenState.Pending)
is TangemPayDetailsBalanceBlockState.Loading -> balanceState.copy(frozenState = CardFrozenState.Pending)
is TangemPayDetailsBalanceBlockState.Content -> balanceState.copy(
isBalanceFlickering = true,
frozenState = CardFrozenState.Pending,
)
}
return prevState.copy(balanceBlockState = newBalanceBlockState)
}
}

View file

@ -4,31 +4,39 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.*
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItem
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItemType.FreezeCard
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItemType.UnfreezeCard
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
internal class TangemPayFreezeUnfreezeStateTransformer(
private val frozen: Boolean,
private val cardFrozenState: TangemPayCardFrozenState,
private val onFreezeClick: () -> Unit,
private val onUnfreezeClick: () -> Unit,
private val converter: TangemPayCardFrozenStateConverter,
) : Transformer<TangemPayDetailsUM> {
private val cardFrozenState = if (frozen) CardFrozenState.Frozen(onUnfreezeClick) else CardFrozenState.Unfrozen
private val removeType =
if (frozen) TangemPayDetailsTopBarMenuItemType.FreezeCard else TangemPayDetailsTopBarMenuItemType.UnfreezeCard
private val addType =
if (frozen) TangemPayDetailsTopBarMenuItemType.UnfreezeCard else TangemPayDetailsTopBarMenuItemType.FreezeCard
private val isCardFrozen: Boolean = when (cardFrozenState) {
is TangemPayCardFrozenState.Frozen -> true
is TangemPayCardFrozenState.Unfrozen, is TangemPayCardFrozenState.Pending -> false
}
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
val dropdownMenuItems = createUpdatedMenuItems(prevState.topBarConfig.items)
val newBalanceBlockState = createUpdatedBalanceState(prevState.balanceBlockState)
val dropdownMenuItems = when (cardFrozenState) {
TangemPayCardFrozenState.Frozen,
TangemPayCardFrozenState.Unfrozen,
-> createUpdatedMenuItems(prevState.topBarConfig.items)
TangemPayCardFrozenState.Pending -> prevState.topBarConfig.items
}
return prevState.copy(
topBarConfig = prevState.topBarConfig.copy(items = dropdownMenuItems),
balanceBlockState = newBalanceBlockState,
cardFrozenState = converter.convert(cardFrozenState),
)
}
@ -36,38 +44,25 @@ internal class TangemPayFreezeUnfreezeStateTransformer(
items: ImmutableList<TangemPayDetailsTopBarMenuItem>?,
): ImmutableList<TangemPayDetailsTopBarMenuItem>? {
return items
?.filter { it.type != removeType }
?.filterNot { it.type == UnfreezeCard || it.type == FreezeCard }
?.plus(createMenuItemToAdd())
?.toPersistentList()
}
private fun createMenuItemToAdd(): TangemPayDetailsTopBarMenuItem {
return TangemPayDetailsTopBarMenuItem(
type = addType,
type = if (isCardFrozen) UnfreezeCard else FreezeCard,
dropdownItem = TangemDropdownMenuItem(
title = resourceReference(
id = if (frozen) {
id = if (isCardFrozen) {
R.string.tangempay_card_details_unfreeze_card
} else {
R.string.tangempay_card_details_freeze_card
},
),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = if (frozen) onUnfreezeClick else onFreezeClick,
onClick = if (isCardFrozen) onUnfreezeClick else onFreezeClick,
),
)
}
private fun createUpdatedBalanceState(
balanceState: TangemPayDetailsBalanceBlockState,
): TangemPayDetailsBalanceBlockState {
return when (balanceState) {
is TangemPayDetailsBalanceBlockState.Error -> balanceState.copy(frozenState = cardFrozenState)
is TangemPayDetailsBalanceBlockState.Loading -> balanceState.copy(frozenState = cardFrozenState)
is TangemPayDetailsBalanceBlockState.Content -> balanceState.copy(
isBalanceFlickering = false,
frozenState = cardFrozenState,
)
}
}
}

View file

@ -1,19 +0,0 @@
package com.tangem.features.tangempay.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
@Composable
internal fun CardDetailsItem(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
TangemPayCardDetailsBlock(
modifier = modifier
.padding(top = TangemTheme.dimens.spacing12)
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
state = state,
)
}

View file

@ -28,6 +28,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe
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.visa.model.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
@ -64,8 +65,11 @@ internal fun TangemPayAddToWalletScreen(
state = listState,
contentPadding = PaddingValues(bottom = TangemTheme.dimens.spacing16 + bottomBarHeight),
) {
item(key = TangemPayCardDetailsUM::class.java) {
TangemPayCardDetailsBlockItem(component = cardDetailsBlockComponent, state = cardDetailsState)
item(TangemPayCardDetailsUM::class.java) {
cardDetailsBlockComponent.CardDetailsBlockContent(
modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp),
state = cardDetailsState,
)
}
item(key = TangemPayAddToWalletUM::class.java) {
@ -185,6 +189,7 @@ private fun PreviewTangemPayAddToWalletScreen() {
onCopy = {},
onClick = {},
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
),
),
)

View file

@ -1,164 +1,306 @@
package com.tangem.features.tangempay.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
private const val REVEAL_ANIMATION_MILLIS = 500
@Suppress("MagicNumber")
@Composable
internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
val isCardFlipped = !state.isHidden
val animDuration = 900
val zAxisDistance = 50f // distance between camera and Card
// rotate Y-axis with animation
val rotateCardY by animateFloatAsState(
targetValue = if (isCardFlipped) 180f else 0f,
animationSpec = tween(durationMillis = animDuration, easing = EaseInOut),
label = "",
)
// Determine which side to show based on the rotation angle
val shouldShowDetails = rotateCardY > 90f
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(204f / 130f) // size of img_tangem_pay_visa
.graphicsLayer {
rotationY = rotateCardY
cameraDistance = zAxisDistance
}
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = state.onClick)
.background(Color(red = 18, green = 21, blue = 31)),
) {
if (shouldShowDetails) {
TangemPayCardDetailsShownBlock(
cardNumber = state.number,
expiry = state.expiry,
cvv = state.cvv,
onCopyCardNumber = { state.onCopy(state.number) },
onCopyCvv = { state.onCopy(state.cvv) },
onCopyExpiry = { state.onCopy(state.expiry) },
onHideDetails = state.onClick,
modifier = Modifier.graphicsLayer { rotationY = 180f },
)
} else {
TangemPayCardDetailsHiddenBlock(
cardFrozenState = state.cardFrozenState,
isLoading = state.isLoading,
shortCardNumber = state.number,
onShowDetails = state.onClick,
)
}
}
}
@Composable
internal fun TangemPayCardDetailsBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
val alpha = if (state.isLoading) {
val infiniteTransition = rememberInfiniteTransition()
val animation by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(REVEAL_ANIMATION_MILLIS, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
)
animation
} else {
1f
}
Column(
modifier = modifier
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersMedium,
),
) {
CardDetailsBlockHeader(state)
CardDetailsTextContainer(
modifier = Modifier
.graphicsLayer { this.alpha = alpha }
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp, top = 4.dp),
text = state.number,
onCopy = { state.onCopy(state.number) },
isHidden = state.isHidden,
private fun TangemPayCardDetailsHiddenBlock(
shortCardNumber: String,
cardFrozenState: TangemPayCardFrozenState,
isLoading: Boolean,
onShowDetails: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
val imageResId = when (cardFrozenState) {
is TangemPayCardFrozenState.Frozen -> R.drawable.img_frozen_tangem_pay_visa
else -> R.drawable.img_tangem_pay_visa
}
Image(
modifier = Modifier.fillMaxSize(),
painter = painterResource(id = imageResId),
contentDescription = null,
)
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = shortCardNumber,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.constantWhite,
)
when (cardFrozenState) {
is TangemPayCardFrozenState.Frozen -> Icon(
modifier = Modifier
.padding(start = 4.dp)
.size(16.dp),
painter = painterResource(id = R.drawable.ic_snow_24),
contentDescription = null,
tint = TangemTheme.colors.icon.constant,
)
TangemPayCardFrozenState.Pending -> CircularProgressIndicator(
modifier = Modifier
.padding(start = 4.dp)
.size(16.dp),
color = TangemTheme.colors.text.constantWhite,
strokeWidth = 1.dp,
)
TangemPayCardFrozenState.Unfrozen -> Unit
}
SpacerWMax()
PrimaryButton(
text = stringResourceSafe(id = R.string.tangempay_card_details_show_details),
showProgress = isLoading,
onClick = onShowDetails,
size = TangemButtonSize.Small,
)
}
}
}
@Suppress("MagicNumber", "LongParameterList")
@Composable
private fun TangemPayCardDetailsShownBlock(
cardNumber: String,
expiry: String,
cvv: String,
onCopyCardNumber: () -> Unit,
onCopyExpiry: () -> Unit,
onCopyCvv: () -> Unit,
onHideDetails: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.padding(8.dp)
.fillMaxSize(),
) {
CardDetailsTextContainer(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp, bottom = 12.dp, top = 8.dp),
.weight(1f),
title = stringResourceSafe(R.string.tangempay_card_details_card_number),
text = cardNumber,
onCopy = onCopyCardNumber,
)
HorizontalDivider(thickness = 1.dp, color = Color(0x1F828282)) // Hardcode since background is an image
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CardDetailsTextContainer(
modifier = Modifier
.graphicsLayer { this.alpha = alpha }
.weight(1f),
text = state.expiry,
onCopy = { state.onCopy(state.expiry) },
isHidden = state.isHidden,
.weight(1f)
.wrapContentHeight(),
title = stringResourceSafe(R.string.tangempay_card_details_expiry),
text = expiry,
onCopy = onCopyExpiry,
)
VerticalDivider(thickness = 1.dp, color = Color(0x1F828282)) // Hardcode since background is an image
CardDetailsTextContainer(
modifier = Modifier
.graphicsLayer { this.alpha = alpha }
.weight(1f),
text = state.cvv,
onCopy = { state.onCopy(state.cvv) },
isHidden = state.isHidden,
.weight(1f)
.wrapContentHeight(),
title = stringResourceSafe(R.string.tangempay_card_details_cvc),
text = cvv,
onCopy = onCopyCvv,
)
}
Row {
SpacerWMax()
PrimaryButton(
text = stringResourceSafe(id = R.string.tangempay_card_details_hide_details),
onClick = onHideDetails,
size = TangemButtonSize.Small,
)
}
}
}
@Composable
private fun CardDetailsBlockHeader(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) {
private fun CardDetailsTextContainer(title: String, text: String, onCopy: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth(),
modifier = modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = title,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Text(
text = text,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.constantWhite,
)
}
IconButton(
modifier = Modifier
.padding(start = 12.dp, top = 12.dp),
text = stringResourceSafe(R.string.tangempay_card_details_title),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
)
Text(
modifier = Modifier
.padding(top = 8.dp, end = 4.dp)
.clip(TangemTheme.shapes.roundedCornersMedium)
.clickable(onClick = state.onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
text = state.buttonText.resolveReference(),
color = TangemTheme.colors.text.accent,
style = TangemTheme.typography.body2,
)
.padding(end = TangemTheme.dimens.spacing12)
.size(TangemTheme.dimens.size32),
onClick = onCopy,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_copy_new_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}
}
@Preview(widthDp = 400, heightDp = 700, showBackground = true)
@Composable
private fun CardDetailsTextContainer(
text: String,
isHidden: Boolean,
onCopy: () -> Unit,
modifier: Modifier = Modifier,
private fun TangemPayCardDetailsBlockV2Preview(
@PreviewParameter(TangemPayCardDetailsUMProvider::class) state: TangemPayCardDetailsUM,
) {
Row(
modifier = modifier
.heightIn(min = 48.dp)
.background(color = TangemTheme.colors.field.primary, shape = RoundedCornerShape(16.dp)),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp),
text = text,
maxLines = 1,
color = if (isHidden) TangemTheme.colors.text.disabled else TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
)
if (!isHidden) {
Spacer(modifier = Modifier.weight(1f))
IconButton(
TangemThemePreview {
Box(modifier = Modifier.fillMaxSize()) {
TangemPayCard(
state = state,
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size32),
onClick = onCopy,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size24),
painter = painterResource(id = R.drawable.ic_copy_new_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
.padding(horizontal = 16.dp)
.padding(top = 12.dp),
)
}
}
}
}
private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvider<TangemPayCardDetailsUM>(
collection = listOf(
TangemPayCardDetailsUM(
isLoading = false,
onClick = {},
number = "*1245",
expiry = "",
cvv = "",
buttonText = resourceReference(R.string.tangempay_card_details_show_details),
onCopy = {},
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Frozen,
),
TangemPayCardDetailsUM(
isLoading = false,
onClick = {},
number = "*1245",
expiry = "",
cvv = "",
buttonText = resourceReference(R.string.tangempay_card_details_show_details),
onCopy = {},
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
),
TangemPayCardDetailsUM(
isLoading = false,
onClick = {},
number = "*1245",
expiry = "",
cvv = "",
buttonText = resourceReference(R.string.tangempay_card_details_show_details),
onCopy = {},
isHidden = true,
cardFrozenState = TangemPayCardFrozenState.Pending,
),
TangemPayCardDetailsUM(
isLoading = false,
onClick = {},
buttonText = resourceReference(R.string.tangempay_card_details_hide_details),
onCopy = {},
isHidden = false,
number = "1234 5678 9012 3456",
expiry = "12/34",
cvv = "123",
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
),
),
)

View file

@ -1,15 +0,0 @@
package com.tangem.features.tangempay.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
@Composable
internal fun TangemPayCardDetailsBlockItem(
component: TangemPayCardDetailsBlockComponent,
state: TangemPayCardDetailsUM,
modifier: Modifier = Modifier,
) {
component.CardDetailsBlockContent(state = state, modifier = modifier)
}

View file

@ -7,11 +7,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@ -35,10 +36,10 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.TokenDetailsTopBarTestTags
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent
@ -48,6 +49,7 @@ import com.tangem.features.tangempay.entity.*
import com.tangem.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
internal fun TangemPayDetailsScreen(
state: TangemPayDetailsUM,
@ -77,12 +79,33 @@ internal fun TangemPayDetailsScreen(
bottom = TangemTheme.dimens.spacing16 + bottomBarHeight,
),
) {
item(TangemPayCardDetailsUM::class.java) {
cardDetailsBlockComponent.CardDetailsBlockContent(
modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp),
state = cardDetailsState,
)
}
when (state.cardFrozenState) {
is CardFrozenState.Frozen -> item(CardFrozenState.Frozen::class.java) {
PrimaryButton(
modifier = Modifier
.animateItem()
.padding(horizontal = 16.dp)
.padding(top = 12.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.tangempay_card_details_unfreeze_card),
onClick = state.cardFrozenState.onUnfreeze,
)
}
else -> Unit
}
item(
key = TangemPayDetailsBalanceBlockState::class.java,
content = {
TangemPayDetailsBalanceBlock(
modifier = modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(top = 12.dp)
.fillMaxWidth(),
state = state.balanceBlockState,
isBalanceHidden = state.isBalanceHidden,
@ -102,23 +125,6 @@ internal fun TangemPayDetailsScreen(
},
)
}
item(TangemPayCardDetailsUM::class.java) {
TangemPayCardDetailsBlockItem(component = cardDetailsBlockComponent, state = cardDetailsState)
}
when (val cardFrozenState = state.balanceBlockState.frozenState) {
is CardFrozenState.Frozen -> item(CardFrozenState.Frozen::class.java) {
PrimaryButton(
modifier = Modifier
.animateItem()
.padding(horizontal = 16.dp)
.padding(top = 12.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.tangempay_card_details_unfreeze_card),
onClick = cardFrozenState.onUnfreeze,
)
}
else -> Unit
}
with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) }
}
}
@ -140,20 +146,12 @@ internal fun TangemPayDetailsBalanceBlock(
)
.padding(vertical = 12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(R.string.tangempay_title),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
)
TangemPayCardStatus(state = state.frozenState)
}
Text(
modifier = Modifier.padding(start = 12.dp),
text = stringResourceSafe(R.string.tangempay_title),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
)
FiatBalance(
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
state = state,
@ -176,36 +174,6 @@ internal fun TangemPayDetailsBalanceBlock(
}
}
@Composable
private fun TangemPayCardStatus(state: CardFrozenState, modifier: Modifier = Modifier) {
when (state) {
is CardFrozenState.Unfrozen -> Unit
is CardFrozenState.Pending -> CircularProgressIndicator(
modifier = modifier.size(18.dp),
trackColor = Color.Transparent,
color = TangemColorPalette.Azure,
strokeWidth = 2.dp,
)
is CardFrozenState.Frozen -> Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_snow_24),
tint = TangemTheme.colors.text.disabled,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(
text = stringResourceSafe(R.string.tangem_pay_card_frozen),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.body2,
)
}
}
}
@Composable
private fun FiatBalance(
state: TangemPayDetailsBalanceBlockState,
@ -224,30 +192,18 @@ private fun FiatBalance(
text = state.fiatBalance.orMaskWithStars(isBalanceHidden),
style = TangemTheme.typography.h2.applyBladeBrush(
isEnabled = state.isBalanceFlickering,
textColor = getTextColor(
isCardFrozen = state.frozenState is CardFrozenState.Frozen,
defaultColor = TangemTheme.colors.text.primary1,
),
textColor = TangemTheme.colors.text.primary1,
),
)
is TangemPayDetailsBalanceBlockState.Error -> Text(
modifier = modifier,
text = DASH_SIGN.orMaskWithStars(isBalanceHidden),
style = TangemTheme.typography.h2,
color = getTextColor(
isCardFrozen = state.frozenState is CardFrozenState.Frozen,
defaultColor = TangemTheme.colors.text.primary1,
),
color = TangemTheme.colors.text.primary1,
)
}
}
@ReadOnlyComposable
@Composable
private fun getTextColor(isCardFrozen: Boolean, defaultColor: Color): Color {
return if (isCardFrozen) TangemTheme.colors.text.tertiary else defaultColor
}
@Suppress("UnusedPrivateMember")
@Composable
private fun CryptoBalance(
@ -351,6 +307,7 @@ private fun TangemPayDetailsScreenPreview(
onCopy = {},
onClick = {},
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
),
),
)
@ -373,22 +330,20 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
cryptoBalance = "1234.56 USDT",
fiatBalance = "$1234.56",
isBalanceFlickering = false,
frozenState = CardFrozenState.Frozen(onUnfreeze = {}),
),
addToWalletBlockState = AddToWalletBlockState({}, {}),
isBalanceHidden = false,
addFundsEnabled = true,
cardFrozenState = CardFrozenState.Frozen(onUnfreeze = {}),
),
TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, items = null),
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(
actionButtons = persistentListOf(),
frozenState = CardFrozenState.Pending,
),
balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()),
addToWalletBlockState = AddToWalletBlockState({}, {}),
isBalanceHidden = false,
addFundsEnabled = true,
cardFrozenState = CardFrozenState.Unfrozen,
),
),
)
@ -410,6 +365,7 @@ private fun TangemPayDetailsTxHistoryScreenPreview(
onCopy = {},
onClick = {},
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
),
),
)

View file

@ -1,19 +0,0 @@
package com.tangem.features.tangempay.utils
internal object CardDetailsFormatUtil {
private const val CARD_NUMBER_CHUNK_LENGTH = 4
private const val DATE_LENGTH = 2
fun formatCardNumber(cardNumber: String): String {
return cardNumber
.filterNot { it.isWhitespace() }
.chunked(CARD_NUMBER_CHUNK_LENGTH).joinToString(" ")
}
fun formatDate(month: String, year: String): String {
val monthFormatted = month.padStart(DATE_LENGTH, '0')
val yearFormatted = year.takeLast(DATE_LENGTH)
return "$monthFormatted/$yearFormatted"
}
}

View file

@ -20,10 +20,12 @@ import com.tangem.domain.nft.ObserveAndClearNFTCacheIfNeedUseCase
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.usecase.TangemPayIssueOrderUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.settings.*
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.wallets.usecase.*
import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
@ -98,6 +100,7 @@ internal class WalletModel @Inject constructor(
private val tangemPayOnboardingRepository: OnboardingRepository,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -376,9 +379,13 @@ internal class WalletModel @Inject constructor(
private suspend fun refreshTangemPayInfo() {
val info = tangemPayMainScreenCustomerInfoUseCase()
if (info != null) {
val cardFrozenState =
info.info.productInstance?.cardId?.let { cardDetailsRepository.cardFrozenStateSync(it) }
?: TangemPayCardFrozenState.Unfrozen
stateHolder.update(
transformer = TangemPayInitialStateTransformer(
value = info,
cardFrozenState = cardFrozenState,
onClickIssue = ::issueOrder,
onClickKyc = innerWalletRouter::openTangemPayOnboarding,
openDetails = { config ->

View file

@ -9,6 +9,7 @@ import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.MainScreenCustomerInfo
import com.tangem.domain.pay.model.OrderStatus.CANCELED
import com.tangem.domain.pay.model.OrderStatus.UNKNOWN
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.util.TangemPayStateCreator.createIssueAvailableState
@ -24,6 +25,7 @@ private const val POLYGON_CHAIN_ID = 137
internal class TangemPayInitialStateTransformer(
private val value: MainScreenCustomerInfo? = null,
private val cardFrozenState: TangemPayCardFrozenState,
private val onClickIssue: () -> Unit = {},
private val onClickKyc: () -> Unit = {},
private val openDetails: (config: TangemPayDetailsConfig) -> Unit = {},
@ -54,10 +56,7 @@ internal class TangemPayInitialStateTransformer(
openDetails(
TangemPayDetailsConfig(
cardId = productInstance.cardId,
isCardFrozen = when (productInstance.status) {
ProductInstance.Status.ACTIVE -> false
ProductInstance.Status.INACTIVE -> true
},
cardFrozenState = cardFrozenState,
customerWalletAddress = cardInfo.customerWalletAddress,
cardNumberEnd = cardInfo.lastFourDigits,
chainId = POLYGON_CHAIN_ID,