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