From d46801014674acd94bce26c161dbd2468c4c5d3f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Oct 2025 18:40:43 +0400 Subject: [PATCH] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- .../models/response/CardDetailsResponse.kt | 10 +- core/res/src/main/res/values/strings.xml | 5 + .../DefaultCardDetailsRepository.kt | 52 ++++++- .../repository/TangemPayRequestPerformer.kt | 12 +- .../tangem/data/pay/util/RainCryptoUtil.kt | 69 ++++++++ .../com/tangem/data/visa/config/VisaConfig.kt | 2 + .../DefaultTangemPayDetailsComponent.kt | 7 +- .../tangempay/entity/TangemPayDetailsUM.kt | 12 +- .../tangempay/model/TangemPayDetailsModel.kt | 85 +++++++--- .../DetailsBalanceTransformer.kt} | 4 +- .../DetailsHiddenStateTransformer.kt | 38 +++++ .../DetailsRevealProgressStateTransformer.kt | 22 +++ .../DetailsRevealedStateTransformer.kt | 29 ++++ .../tangempay/ui/TangemPayDetailsScreen.kt | 147 ++++++++++++++---- .../tangempay/utils/CardDetailsFormatUtil.kt | 19 +++ 16 files changed, 457 insertions(+), 58 deletions(-) create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt rename features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/{transformer/CardDetailsStateTransformer.kt => model/transformers/DetailsBalanceTransformer.kt} (94%) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/CardDetailsFormatUtil.kt diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 5cf863d2ad..f84ecbedab 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 5cf863d2ad68d89a111520aca3504fe9db234c82 +Subproject commit f84ecbedab4d34f0e92ed8ef7f529818cbe2def0 diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardDetailsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardDetailsResponse.kt index c571d6a90a..978baa11f9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardDetailsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CardDetailsResponse.kt @@ -16,7 +16,13 @@ data class CardDetailsResponse( @Json(name = "card_type") val cardType: String, @Json(name = "card_status") val cardStatus: String, @Json(name = "card_number_end") val cardNumberEnd: String, - @Json(name = "pan") val pan: String, - @Json(name = "cvv") val cvv: String, + @Json(name = "pan") val pan: Secret, + @Json(name = "cvv") val cvv: Secret, + ) + + @JsonClass(generateAdapter = true) + data class Secret( + @Json(name = "secret") val secret: String, + @Json(name = "iv") val iv: String, ) } \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 94d0e6a553..a8e17d7044 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1280,6 +1280,10 @@ You receive Choose token not available + Failed to load data. Try again later. + Hide + Reveal + Card details Create account and issue a card Tangem Pay View Status @@ -1292,6 +1296,7 @@ Your card details are protected — full control in the app. Built-in security Get your free Crypto Card\nin minutes + Tangem Pay This is my wallet Balances hidden Balances shown diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt index c5365b7b17..97b84bd4e9 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCardDetailsRepository.kt @@ -2,7 +2,13 @@ package com.tangem.data.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.data.pay.util.RainCryptoUtil +import com.tangem.data.visa.config.VisaLibLoader +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.CardDetailsRequest import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails import com.tangem.domain.pay.repository.CardDetailsRepository @@ -13,6 +19,9 @@ private const val TAG = "TangemPay: CardDetailsRepository" internal class DefaultCardDetailsRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, + private val visaLibLoader: VisaLibLoader, + private val apiConfigsManager: ApiConfigsManager, + private val rainCryptoUtil: RainCryptoUtil, ) : CardDetailsRepository { override suspend fun getCardBalance(): Either { @@ -28,6 +37,47 @@ internal class DefaultCardDetailsRepository @Inject constructor( } override suspend fun revealCardDetails(): Either { - TODO("[REDACTED_TASK_KEY] add reveal card details") + return requestHelper.runWithErrorLogs(TAG) { + val publicKeyBase64 = getPublicKeyBase64() + val (secretKeyHex, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64) + + val result = requestHelper.request { authHeader -> + tangemPayApi.revealCardDetails( + authHeader = authHeader, + body = CardDetailsRequest(sessionId = sessionId), + ) + }.result ?: error("Cannot reveal card details") + + val pan = rainCryptoUtil.decryptSecret( + base64Secret = result.pan.secret, + base64Iv = result.pan.iv, + secretKeyHex = secretKeyHex, + ) + + val cvv = rainCryptoUtil.decryptSecret( + base64Secret = result.cvv.secret, + base64Iv = result.cvv.iv, + secretKeyHex = secretKeyHex, + ) + + TangemPayCardDetails( + pan = pan, + cvv = cvv, + expirationYear = result.expirationYear, + expirationMonth = result.expirationMonth, + ) + } + } + + private suspend fun getPublicKeyBase64(): String { + val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment + return when (env) { + ApiEnvironment.DEV, + ApiEnvironment.DEV_2, + ApiEnvironment.STAGE, + ApiEnvironment.MOCK, + -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.dev + ApiEnvironment.PROD -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.prod + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt index dffb6ede24..16f0d536d2 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/TangemPayRequestPerformer.kt @@ -21,6 +21,7 @@ import com.tangem.domain.wallets.derivations.derivationStyleProvider import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -58,8 +59,15 @@ internal class TangemPayRequestPerformer @Inject constructor( val result = requestBlock() Either.Right(result) } catch (exception: Exception) { - Timber.e("$tag: $exception") - Either.Left(mapError(exception)) + when (exception) { + is CancellationException -> { + throw exception + } + else -> { + Timber.tag(tag).e(exception) + Either.Left(mapError(exception)) + } + } } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt new file mode 100644 index 0000000000..24b60d6ea8 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/RainCryptoUtil.kt @@ -0,0 +1,69 @@ +package com.tangem.data.pay.util + +import android.util.Base64 +import com.tangem.utils.extensions.hexToBytes +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.SecureRandom +import com.tangem.common.extensions.toHexString +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.inject.Inject + +private const val KEY_LENGTH_BYTES = 16 +private const val TAG_LENGTH_BYTES = 16 +private const val BITS_PER_BYTE = 8 +private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding" +private const val AES_TRANSFORMATION = "AES/GCM/NoPadding" +private const val AES_ALGORITHM = "AES" +private const val RSA_ALGORITHM = "RSA" + +internal class RainCryptoUtil @Inject constructor( + private val dispatchers: CoroutineDispatcherProvider, +) { + + suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair = + withContext(dispatchers.default) { + val secretKeyHex = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }.toHexString() + val sessionId = generateSessionId(publicKeyBase64, secretKeyHex) + secretKeyHex to sessionId + } + + suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyHex: String): String = + withContext(dispatchers.default) { + val cipherTextBytes = Base64.decode(base64Secret, Base64.NO_WRAP) + if (cipherTextBytes.size < TAG_LENGTH_BYTES) error("Cipher text too short") + + val initializationVectorBytes = Base64.decode(base64Iv, Base64.NO_WRAP) + val secretKeyBytes = secretKeyHex.hexToBytes() + + val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM) + val gcmParameterSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, initializationVectorBytes) + + val aesCipher = Cipher.getInstance(AES_TRANSFORMATION) + aesCipher.init(Cipher.DECRYPT_MODE, aesSecretKey, gcmParameterSpec) + + val plaintextBytes = aesCipher.doFinal(cipherTextBytes) + plaintextBytes.toString(StandardCharsets.UTF_8).trim().ifEmpty { error("Invalid decrypted data") } + } + + private fun generateSessionId(publicKeyBase64: String, secretKeyHex: String): String { + val secretKeyBytes = secretKeyHex.hexToBytes() + val publicKeyDerBytes = Base64.decode(publicKeyBase64, Base64.NO_WRAP) + val publicKeySpec = X509EncodedKeySpec(publicKeyDerBytes) + val rsaPublicKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(publicKeySpec) + + val secretKeyBase64String = Base64.encodeToString(secretKeyBytes, Base64.NO_WRAP) + val plaintextUtf8Bytes = secretKeyBase64String.toByteArray(StandardCharsets.UTF_8) + + val rsaCipher = Cipher.getInstance(RSA_TRANSFORMATION) + rsaCipher.init(Cipher.ENCRYPT_MODE, rsaPublicKey) + val cipherTextBytes = rsaCipher.doFinal(plaintextUtf8Bytes) + + return Base64.encodeToString(cipherTextBytes, Base64.NO_WRAP) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/config/VisaConfig.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/config/VisaConfig.kt index cf76110918..d70626efc0 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/config/VisaConfig.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/config/VisaConfig.kt @@ -13,6 +13,8 @@ internal data class VisaConfig( val header: Header, @Json(name = "rsaPublicKey") val rsaPublicKey: RsaPublicKey, + @Json(name = "rainRSAPublicKey") + val rainRSAPublicKey: RsaPublicKey, ) { @JsonClass(generateAdapter = true) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt index 6f38bea920..0aa60f2cf3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsComponent.kt @@ -29,8 +29,13 @@ internal class DefaultTangemPayDetailsComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + NavigationBar3ButtonsScrim() - TangemPayDetailsScreen(state = state, txHistoryComponent = txHistoryComponent, modifier = modifier) + TangemPayDetailsScreen( + state = state, + txHistoryComponent = txHistoryComponent, + modifier = modifier, + ) } @AssistedFactory 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 dbb7977543..cad1301dc5 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 @@ -2,6 +2,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 kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsUM( @@ -12,7 +13,16 @@ internal data class TangemPayDetailsUM( val isBalanceHidden: Boolean, ) -data class TangemPayCardDetailsUM(val number: String, val expiry: String, val cvv: String, val onReveal: () -> Unit) +data class TangemPayCardDetailsUM( + val number: String = "", + val expiry: String = "", + val cvv: String = "", + val buttonText: TextReference = TextReference.EMPTY, + val onClick: () -> Unit = {}, + val onCopy: (String) -> Unit = {}, + val isHidden: Boolean = true, + val isLoading: Boolean = false, +) internal sealed class TangemPayDetailsBalanceBlockState { 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 8bd7f25624..0760c76fda 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 @@ -5,16 +5,20 @@ 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.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.pay.repository.CardDetailsRepository import com.tangem.features.tangempay.components.TangemPayDetailsComponent +import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer -import com.tangem.features.tangempay.transformer.CardDetailsStateTransformer +import com.tangem.features.tangempay.model.transformers.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -33,6 +37,8 @@ internal class TangemPayDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val cardDetailsRepository: CardDetailsRepository, + private val clipboardManager: ClipboardManager, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params: TangemPayDetailsComponent.Params = paramsContainer.require() @@ -42,6 +48,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val refreshStateJobHolder = JobHolder() private val fetchBalanceJobHolder = JobHolder() + private val revealCardDetailsJobHolder = JobHolder() init { fetchBalance() @@ -50,7 +57,7 @@ internal class TangemPayDetailsModel @Inject constructor( private fun fetchBalance(): Job { return modelScope.launch { val result = cardDetailsRepository.getCardBalance() - uiState.update(CardDetailsStateTransformer(result)) + uiState.update(DetailsBalanceTransformer(result)) }.saveIn(fetchBalanceJobHolder) } @@ -62,24 +69,64 @@ internal class TangemPayDetailsModel @Inject constructor( }.saveIn(refreshStateJobHolder) } - private fun getInitialState(): TangemPayDetailsUM { - return TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = router::pop, items = null), - pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = ::onRefreshSwipe), - balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()), - cardDetailsUM = getHiddenCardDetails(), - isBalanceHidden = false, - ) + private fun getInitialState() = TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = router::pop, items = null), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = ::onRefreshSwipe), + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(actionButtons = persistentListOf()), + cardDetailsUM = TangemPayCardDetailsUM(), + isBalanceHidden = false, + ).let { + DetailsHiddenStateTransformer( + onClickReveal = ::revealCardDetails, + cardNumberEnd = params.cardNumberEnd, + ).transform(it) } - private fun getHiddenCardDetails() = TangemPayCardDetailsUM( - number = "•••• •••• •••• ${params.cardNumberEnd}", - expiry = "••/••", - cvv = "•••", - onReveal = ::revealCardDetails, - ) - private fun revealCardDetails() { - // TODO "[REDACTED_TASK_KEY] add reveal card details" + modelScope.launch { + uiState.update( + transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails), + ) + cardDetailsRepository.revealCardDetails() + .onRight { + uiState.update( + transformer = DetailsRevealedStateTransformer( + details = it, + onClickHide = ::hideCardDetails, + onClickCopy = ::copyData, + ), + ) + } + .onLeft { + uiState.update( + transformer = DetailsHiddenStateTransformer( + onClickReveal = ::revealCardDetails, + cardNumberEnd = params.cardNumberEnd, + ), + ) + showError() + } + }.saveIn(revealCardDetailsJobHolder) + } + + private fun hideCardDetails() { + modelScope.launch { + revealCardDetailsJobHolder.cancel() + uiState.update( + transformer = DetailsHiddenStateTransformer( + onClickReveal = ::revealCardDetails, + cardNumberEnd = params.cardNumberEnd, + ), + ) + } + } + + private fun copyData(text: String) { + clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true) + } + private fun showError() { + uiMessageSender.send( + SnackbarMessage(TextReference.Res(R.string.tangempay_card_details_error_text)), + ) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/transformer/CardDetailsStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt similarity index 94% rename from features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/transformer/CardDetailsStateTransformer.kt rename to features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index 1d224b5e6a..14627ed23d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/transformer/CardDetailsStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -1,4 +1,4 @@ -package com.tangem.features.tangempay.transformer +package com.tangem.features.tangempay.model.transformers import arrow.core.Either import com.tangem.core.error.UniversalError @@ -11,7 +11,7 @@ import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.persistentListOf import java.util.Currency -internal class CardDetailsStateTransformer( +internal class DetailsBalanceTransformer( private val balance: Either, ) : Transformer { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt new file mode 100644 index 0000000000..35025ce2a3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsHiddenStateTransformer.kt @@ -0,0 +1,38 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.utils.CardDetailsFormatUtil +import com.tangem.utils.StringsSigns +import com.tangem.utils.transformer.Transformer + +private const val CARD_NUMBER_SART_DIGITS_COUNT = 12 +private const val DATE_PART_LENGTH = 2 +private const val CVV_LENGTH = 3 + +internal class DetailsHiddenStateTransformer( + private val onClickReveal: () -> Unit, + private val cardNumberEnd: String, +) : Transformer { + + private val cardStartMasked = maskedBlock(CARD_NUMBER_SART_DIGITS_COUNT) + private val dateMasked = maskedBlock(DATE_PART_LENGTH) + private val cvvMasked = maskedBlock(CVV_LENGTH) + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + val cardDetailsUM = 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), + onClick = onClickReveal, + onCopy = {}, + isHidden = true, + ) + return prevState.copy(cardDetailsUM = cardDetailsUM) + } + + private fun maskedBlock(count: Int) = StringsSigns.DOT.repeat(count) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt new file mode 100644 index 0000000000..b4c1968160 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealProgressStateTransformer.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class DetailsRevealProgressStateTransformer( + private val onClickHide: (() -> Unit), +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + return prevState.copy( + cardDetailsUM = prevState.cardDetailsUM.copy( + buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), + onClick = onClickHide, + isLoading = true, + isHidden = true, + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt new file mode 100644 index 0000000000..45ba18f069 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsRevealedStateTransformer.kt @@ -0,0 +1,29 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.core.ui.extensions.TextReference +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.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.utils.CardDetailsFormatUtil +import com.tangem.utils.transformer.Transformer + +internal class DetailsRevealedStateTransformer( + private val details: TangemPayCardDetails, + private val onClickHide: (() -> Unit), + private val onClickCopy: ((String) -> Unit), +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + val cardDetailsUM = TangemPayCardDetailsUM( + number = CardDetailsFormatUtil.formatCardNumber(cardNumber = details.pan), + expiry = CardDetailsFormatUtil.formatDate(month = details.expirationMonth, year = details.expirationYear), + cvv = details.cvv, + onClick = onClickHide, + buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), + onCopy = onClickCopy, + isHidden = false, + ) + return prevState.copy(cardDetailsUM = cardDetailsUM) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 9d184bae20..c2e6cc25f7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -2,6 +2,12 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility +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.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -12,10 +18,13 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -33,9 +42,13 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu +import com.tangem.core.ui.components.snackbar.TangemSnackbarHost import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsTopBarTestTags @@ -49,17 +62,26 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf +private const val REVEAL_ANIMATION_MILLIS = 500 + @Composable internal fun TangemPayDetailsScreen( state: TangemPayDetailsUM, txHistoryComponent: TangemPayTxHistoryComponent, modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { Scaffold( modifier = modifier, topBar = { TangemPayDetailsTopAppBar(config = state.topBarConfig) }, contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), containerColor = TangemTheme.colors.background.secondary, + snackbarHost = { + TangemSnackbarHost( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + hostState = snackbarHostState, + ) + }, ) { scaffoldPaddings -> val listState = rememberLazyListState() val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } @@ -124,7 +146,7 @@ internal fun TangemPayDetailsBalanceBlock( ) { Text( modifier = Modifier.padding(start = 12.dp), - text = "Tangem Pay", + text = stringResourceSafe(R.string.tangempay_title), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, ) @@ -215,52 +237,97 @@ private fun CryptoBalance( // region Card details block @Composable private 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, - ) - .padding(all = 12.dp), + ), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Card details", - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.subtitle2, - ) - Text( - modifier = Modifier.clickable(onClick = state.onReveal), - text = "Reveal", - color = TangemTheme.colors.text.accent, - style = TangemTheme.typography.body2, - ) - } + CardDetailsBlockHeader(state) CardDetailsTextContainer( modifier = Modifier + .graphicsLayer { this.alpha = alpha } .fillMaxWidth() - .padding(top = 4.dp), + .padding(start = 12.dp, end = 12.dp, top = 4.dp), text = state.number, + onCopy = { state.onCopy(state.number) }, + isHidden = state.isHidden, ) Row( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp), + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp, top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - CardDetailsTextContainer(modifier = Modifier.weight(1f), text = state.expiry) - CardDetailsTextContainer(modifier = Modifier.weight(1f), text = state.cvv) + CardDetailsTextContainer( + modifier = Modifier + .graphicsLayer { this.alpha = alpha } + .weight(1f), + text = state.expiry, + onCopy = { state.onCopy(state.expiry) }, + isHidden = state.isHidden, + ) + CardDetailsTextContainer( + modifier = Modifier + .graphicsLayer { this.alpha = alpha } + .weight(1f), + text = state.cvv, + onCopy = { state.onCopy(state.cvv) }, + isHidden = state.isHidden, + ) } } } @Composable -private fun CardDetailsTextContainer(text: String, modifier: Modifier = Modifier) { +private fun CardDetailsBlockHeader(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + 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, + ) + } +} + +@Composable +private fun CardDetailsTextContainer( + text: String, + isHidden: Boolean, + onCopy: () -> Unit, + modifier: Modifier = Modifier, +) { Row( modifier = modifier .heightIn(min = 48.dp) @@ -271,9 +338,27 @@ private fun CardDetailsTextContainer(text: String, modifier: Modifier = Modifier modifier = Modifier.padding(vertical = 4.dp, horizontal = 12.dp), text = text, maxLines = 1, - color = TangemTheme.colors.text.disabled, + color = if (isHidden) TangemTheme.colors.text.disabled else TangemTheme.colors.text.primary1, style = TangemTheme.typography.body2, ) + + if (!isHidden) { + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + 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, + ) + } + } } } @@ -367,7 +452,9 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider