Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-03 18:40:43 +04:00
parent 80f829a2aa
commit d468010146
16 changed files with 457 additions and 58 deletions

@ -1 +1 @@
Subproject commit 5cf863d2ad68d89a111520aca3504fe9db234c82
Subproject commit f84ecbedab4d34f0e92ed8ef7f529818cbe2def0

View file

@ -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,
)
}

View file

@ -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>

View file

@ -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
}
}
}

View file

@ -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))
}
}
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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

View file

@ -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 {

View file

@ -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)),
)
}
}

View file

@ -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> {

View file

@ -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)
}

View file

@ -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,
),
)
}
}

View file

@ -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)
}
}

View file

@ -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,
),

View file

@ -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"
}
}