Updated on 2026-08-14
This commit is contained in:
parent
80f829a2aa
commit
d468010146
16 changed files with 457 additions and 58 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 5cf863d2ad68d89a111520aca3504fe9db234c82
|
||||
Subproject commit f84ecbedab4d34f0e92ed8ef7f529818cbe2def0
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1280,6 +1280,10 @@
|
|||
<string name="swapping_to_title">You receive</string>
|
||||
<string name="swapping_token_list_title">Choose token</string>
|
||||
<string name="swapping_token_not_available">not available</string>
|
||||
<string name="tangempay_card_details_error_text">Failed to load data. Try again later.</string>
|
||||
<string name="tangempay_card_details_hide_text">Hide</string>
|
||||
<string name="tangempay_card_details_reveal_text">Reveal</string>
|
||||
<string name="tangempay_card_details_title">Card details</string>
|
||||
<string name="tangempay_issue_card_notification_title">Create account and issue a card</string>
|
||||
<string name="tangempay_kyc_card_ready_notification_title">Tangem Pay</string>
|
||||
<string name="tangempay_kyc_in_progress_notification_button">View Status</string>
|
||||
|
|
@ -1292,6 +1296,7 @@
|
|||
<string name="tangempay_onboarding_security_description">Your card details are protected — full control in the app.</string>
|
||||
<string name="tangempay_onboarding_security_title">Built-in security</string>
|
||||
<string name="tangempay_onboarding_title">Get your free Crypto Card\nin minutes</string>
|
||||
<string name="tangempay_title">Tangem Pay</string>
|
||||
<string name="this_is_my_wallet_title">This is my wallet</string>
|
||||
<string name="toast_balances_hidden">Balances hidden</string>
|
||||
<string name="toast_balances_shown">Balances shown</string>
|
||||
|
|
|
|||
|
|
@ -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<UniversalError, TangemPayCardBalance> {
|
||||
|
|
@ -28,6 +37,47 @@ internal class DefaultCardDetailsRepository @Inject constructor(
|
|||
}
|
||||
|
||||
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, String> =
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UniversalError, TangemPayCardBalance>,
|
||||
) : Transformer<TangemPayDetailsUM> {
|
||||
|
||||
|
|
@ -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<TangemPayDetailsUM> {
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<TangemPayDetailsUM> {
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TangemPayDetailsUM> {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Ta
|
|||
number = "•••• •••• •••• 1245",
|
||||
expiry = "••/••",
|
||||
cvv = "•••",
|
||||
onReveal = {},
|
||||
onCopy = {},
|
||||
onClick = {},
|
||||
buttonText = TextReference.Res(R.string.tangempay_card_details_reveal_text),
|
||||
),
|
||||
isBalanceHidden = false,
|
||||
),
|
||||
|
|
@ -379,7 +466,9 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
|
|||
number = "•••• •••• •••• 1245",
|
||||
expiry = "••/••",
|
||||
cvv = "•••",
|
||||
onReveal = {},
|
||||
onCopy = {},
|
||||
onClick = {},
|
||||
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
|
||||
),
|
||||
isBalanceHidden = false,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue