Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-27 13:07:28 +04:00
parent 5579cc1372
commit a04992591a
24 changed files with 707 additions and 29 deletions

View file

@ -269,8 +269,8 @@ dependencies {
debugImplementation(projects.features.kyc.impl)
internalImplementation(projects.features.kyc.impl)
mockedImplementation(projects.features.kyc.impl)
releaseImplementation(projects.features.kyc.impl)
externalImplementation(projects.features.kyc.impl)
releaseImplementation(projects.features.kyc.mock)
externalImplementation(projects.features.kyc.mock)
implementation(projects.features.welcome.api)
implementation(projects.features.welcome.impl)
implementation(projects.features.createWalletSelection.api)

View file

@ -33,7 +33,7 @@
},
{
"name": "TANGEM_PAY_ENABLED",
"version": "5.30.0"
"version": "undefined"
},
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",

View file

@ -7,6 +7,7 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
@ -146,4 +147,10 @@ interface TangemPayApi {
@Header("Authorization") authHeader: String,
@Body body: CardDetailsRequest,
): ApiResponse<CardDetailsResponse>
@PUT("v1/customer/card/pin")
suspend fun setPin(
@Header("Authorization") authHeader: String,
@Body body: SetPinRequest,
): ApiResponse<SetPinResponse>
}

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SetPinRequest(
@Json(name = "pin") val pin: String,
@Json(name = "session_id") val sessionId: String,
@Json(name = "iv") val iv: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SetPinResponse(
@Json(name = "result") val result: Result?,
@Json(name = "error") val error: String?,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "result") val result: String,
)
}

View file

@ -0,0 +1,6 @@
package com.tangem.data.pay.entity
internal data class EncryptedData(
val encryptedBase64: String,
val ivBase64: String,
)

View file

@ -9,6 +9,8 @@ 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.datasource.api.pay.models.request.SetPinRequest
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.repository.CardDetailsRepository
@ -39,7 +41,7 @@ internal class DefaultCardDetailsRepository @Inject constructor(
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyHex, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val result = requestHelper.request { authHeader ->
tangemPayApi.revealCardDetails(
@ -51,14 +53,15 @@ internal class DefaultCardDetailsRepository @Inject constructor(
val pan = rainCryptoUtil.decryptSecret(
base64Secret = result.pan.secret,
base64Iv = result.pan.iv,
secretKeyHex = secretKeyHex,
secretKeyBytes = secretKeyBytes,
)
val cvv = rainCryptoUtil.decryptSecret(
base64Secret = result.cvv.secret,
base64Iv = result.cvv.iv,
secretKeyHex = secretKeyHex,
secretKeyBytes = secretKeyBytes,
)
secretKeyBytes.fill(0)
TangemPayCardDetails(
pan = pan,
@ -69,6 +72,32 @@ internal class DefaultCardDetailsRepository @Inject constructor(
}
}
override suspend fun setPin(pin: String): Either<UniversalError, SetPinResult> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val encryptedData = rainCryptoUtil.encryptPin(pin = pin, secretKeyBytes = secretKeyBytes)
secretKeyBytes.fill(0)
val status = requestHelper.request { authHeader ->
tangemPayApi.setPin(
authHeader = authHeader,
body = SetPinRequest(
sessionId = sessionId,
pin = encryptedData.encryptedBase64,
iv = encryptedData.ivBase64,
),
)
}.result?.result ?: error("Cannot set pin code")
when (status) {
SetPinResult.SUCCESS.name -> SetPinResult.SUCCESS
SetPinResult.PIN_TOO_WEAK.name -> SetPinResult.PIN_TOO_WEAK
SetPinResult.DECRYPTION_ERROR.name -> SetPinResult.DECRYPTION_ERROR
else -> SetPinResult.UNKNOWN_ERROR
}
}
}
private suspend fun getPublicKeyBase64(): String {
val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment
return when (env) {

View file

@ -1,11 +1,10 @@
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.data.pay.entity.EncryptedData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.security.spec.X509EncodedKeySpec
@ -21,26 +20,38 @@ 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"
private const val IV_LENGTH_BYTES = 16
private const val PIN_LENGTH = 4
private const val PIN_LENGTH_BYTES = 8
private const val PIN_BLOCK_ISO_9564_FORMAT_PREFIX = '2'
private const val PIN_BLOCK_FILL_CHAR = 'F'
internal class RainCryptoUtil @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<String, String> =
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<ByteArray, String> =
withContext(dispatchers.default) {
val secretKeyHex = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }.toHexString()
val sessionId = generateSessionId(publicKeyBase64, secretKeyHex)
secretKeyHex to sessionId
val secretKeyBytes = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val sessionId = generateSessionId(publicKeyBase64, secretKeyBytes)
secretKeyBytes to sessionId
}
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyHex: String): String =
suspend fun encryptPin(pin: String, secretKeyBytes: ByteArray): EncryptedData = withContext(dispatchers.default) {
val bytes = pinBlockByteArray(pin)
try {
encryptSecret(bytes, secretKeyBytes)
} finally {
bytes.clear()
}
}
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyBytes: ByteArray): 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)
@ -51,8 +62,25 @@ internal class RainCryptoUtil @Inject constructor(
plaintextBytes.toString(StandardCharsets.UTF_8).trim().ifEmpty { error("Invalid decrypted data") }
}
private fun generateSessionId(publicKeyBase64: String, secretKeyHex: String): String {
val secretKeyBytes = secretKeyHex.hexToBytes()
private fun encryptSecret(bytes: ByteArray, secretKeyBytes: ByteArray): EncryptedData {
val iv = ByteArray(IV_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM)
val gcmSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, iv)
val cipher = Cipher.getInstance(AES_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, aesSecretKey, gcmSpec)
val ciphertext = cipher.doFinal(bytes)
bytes.clear()
return EncryptedData(
encryptedBase64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP),
ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP),
)
}
private fun generateSessionId(publicKeyBase64: String, secretKeyBytes: ByteArray): String {
val publicKeyDerBytes = Base64.decode(publicKeyBase64, Base64.NO_WRAP)
val publicKeySpec = X509EncodedKeySpec(publicKeyDerBytes)
val rsaPublicKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(publicKeySpec)
@ -66,4 +94,26 @@ internal class RainCryptoUtil @Inject constructor(
return Base64.encodeToString(cipherTextBytes, Base64.NO_WRAP)
}
/**
* Formats PIN into a PIN block using schema: [Prefix][Length][PIN][fill with F].
* Example: 246784FFFFFFFFFF for PIN 6784.
*/
private fun pinBlockByteArray(pin: String): ByteArray {
require(pin.length == PIN_LENGTH) { "PIN length must be $PIN_LENGTH" }
require(pin.all { it.isDigit() }) { "PIN must contain digits only" }
val pinBlockHexLength = PIN_LENGTH_BYTES * 2
val hex = buildString(pinBlockHexLength) {
append(PIN_BLOCK_ISO_9564_FORMAT_PREFIX)
append(PIN_LENGTH.toString())
append(pin)
while (length < pinBlockHexLength) append(PIN_BLOCK_FILL_CHAR)
}
return hex.toByteArray(StandardCharsets.UTF_8)
}
private fun ByteArray.clear() {
for (i in indices) this[i] = 0
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.pay.model
enum class SetPinResult {
SUCCESS, PIN_TOO_WEAK, DECRYPTION_ERROR, UNKNOWN_ERROR
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
@ -10,4 +11,6 @@ interface CardDetailsRepository {
suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance>
suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails>
suspend fun setPin(pin: String): Either<UniversalError, SetPinResult>
}

View file

@ -45,6 +45,7 @@ dependencies {
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.decompose.ext.compose)
implementation(deps.lottie.compose)
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -1,8 +1,8 @@
package com.tangem.features.tangempay.components
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.activity.compose.BackHandler
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.stack.Children
@ -57,18 +57,23 @@ class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor(
componentContext: ComponentContext,
): ComposableContentComponent = when (config) {
TangemPayDetailsInnerRoute.Details -> TangemPayDetailsComponent(
appComponentContext = childByContext(componentContext),
innerRouter = innerRouter,
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = params,
tokenReceiveComponentFactory = tokenReceiveComponentFactory,
)
TangemPayDetailsInnerRoute.ChangePIN -> TODO(" [REDACTED_JIRA]")
TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
)
TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
)
}
private fun onChildBack() {
when (childStack.value.active.configuration) {
TangemPayDetailsInnerRoute.ChangePIN -> stackNavigation.pop()
TangemPayDetailsInnerRoute.Details -> router.pop()
if (childStack.value.backStack.isEmpty()) {
router.pop()
} else {
stackNavigation.pop()
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.features.tangempay.components
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.security.DisableScreenshotsDisposableEffect
import com.tangem.features.tangempay.model.TangemPayChangePinModel
import com.tangem.features.tangempay.ui.TangemPayChangePinScreen
internal class TangemPayChangePinComponent(
private val appComponentContext: AppComponentContext,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: TangemPayChangePinModel = getOrCreateModel()
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
BackHandler(onBack = router::pop)
DisableScreenshotsDisposableEffect()
TangemPayChangePinScreen(
state = state,
onBackClick = router::pop,
)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.tangempay.components
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.tangempay.ui.TangemPayChangePinCodeSuccessScreen
internal class TangemPayChangePinSuccessComponent(
private val appComponentContext: AppComponentContext,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
@Composable
override fun Content(modifier: Modifier) {
BackHandler(onBack = ::backToDetails)
TangemPayChangePinCodeSuccessScreen(onClick = ::backToDetails)
}
private fun backToDetails() {
router.popTo(route = TangemPayDetailsInnerRoute.Details)
}
}

View file

@ -12,25 +12,23 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent
import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent
import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.ui.TangemPayDetailsScreen
import com.tangem.features.tokenreceive.TokenReceiveComponent
internal class TangemPayDetailsComponent(
private val appComponentContext: AppComponentContext,
innerRouter: Router,
private val params: TangemPayDetailsContainerComponent.Params,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: TangemPayDetailsModel = getOrCreateModel(params = params, router = innerRouter)
private val model: TangemPayDetailsModel = getOrCreateModel(params = params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,

View file

@ -2,6 +2,7 @@ package com.tangem.features.tangempay.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.tangempay.model.TangemPayChangePinModel
import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel
import com.tangem.features.tangempay.model.TangemPayTxHistoryModel
@ -29,4 +30,9 @@ internal interface TangemPayModelModule {
@IntoMap
@ClassKey(TangemPayTxHistoryDetailsModel::class)
fun bindTangemPayTxHistoryDetailsModel(model: TangemPayTxHistoryDetailsModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayChangePinModel::class)
fun bindTangemPayChangePinModel(model: TangemPayChangePinModel): Model
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.tangempay.entity
import com.tangem.core.ui.extensions.TextReference
internal data class TangemPayChangePinUM(
val pinCode: String,
val error: TextReference?,
val onPinCodeChange: (String) -> Unit,
val submitButtonLoading: Boolean,
val submitButtonEnabled: Boolean,
val onSubmitClick: () -> Unit,
)

View file

@ -0,0 +1,61 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.repository.CardDetailsRepository
import com.tangem.features.tangempay.entity.TangemPayChangePinUM
import kotlinx.coroutines.flow.*
import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayChangePinModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val cardDetailsRepository: CardDetailsRepository,
) : Model() {
val uiState: StateFlow<TangemPayChangePinUM>
field = MutableStateFlow(getInitialState())
private fun onPinCodeChange(pin: String) {
uiState.update(transformer = PinCodeChangeTransformer(newPin = pin))
}
private fun onClickSubmit() {
modelScope.launch {
uiState.update { it.copy(submitButtonLoading = true) }
val result = cardDetailsRepository.setPin(uiState.value.pinCode).getOrNull()
uiState.update { it.copy(submitButtonLoading = false) }
when (result) {
SetPinResult.SUCCESS -> router.push(TangemPayDetailsInnerRoute.ChangePINSuccess)
SetPinResult.PIN_TOO_WEAK,
SetPinResult.DECRYPTION_ERROR,
SetPinResult.UNKNOWN_ERROR,
null,
-> Unit // TODO: [REDACTED_TASK_KEY] - add error handling once the requirements arrive
}
}
}
private fun getInitialState(): TangemPayChangePinUM {
return TangemPayChangePinUM(
pinCode = "",
onPinCodeChange = ::onPinCodeChange,
onSubmitClick = ::onClickSubmit,
submitButtonEnabled = false,
submitButtonLoading = false,
error = null,
)
}
}

View file

@ -23,6 +23,7 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.model.transformers.*
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
import com.tangem.features.tangempay.utils.TangemPayErrorMessageFactory
import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -75,7 +76,7 @@ internal class TangemPayDetailsModel @Inject constructor(
}
private fun onClickChangePin() {
// TODO [REDACTED_JIRA]
router.push(TangemPayDetailsInnerRoute.ChangePIN)
}
private fun onClickFreezeCard() {

View file

@ -0,0 +1,27 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayChangePinUM
import com.tangem.features.tangempay.utils.PinCodeValidation
import com.tangem.utils.transformer.Transformer
internal class PinCodeChangeTransformer(
private val newPin: String,
) : Transformer<TangemPayChangePinUM> {
override fun transform(prevState: TangemPayChangePinUM): TangemPayChangePinUM {
val valid = PinCodeValidation.validate(pinCode = newPin)
// Do not show error yet if user didn't fill all 4 spaces
val isError = PinCodeValidation.validateLength(pinCode = newPin) && !valid
return prevState.copy(
pinCode = newPin,
submitButtonEnabled = valid,
error = if (isError) {
resourceReference(R.string.visa_onboarding_pin_validation_error_message)
} else {
null
},
)
}
}

View file

@ -10,4 +10,7 @@ internal sealed class TangemPayDetailsInnerRoute : Route {
@Serializable
data object ChangePIN : TangemPayDetailsInnerRoute()
@Serializable
data object ChangePINSuccess : TangemPayDetailsInnerRoute()
}

View file

@ -0,0 +1,127 @@
package com.tangem.features.tangempay.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.appbar.TangemTopAppBar
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.features.tangempay.details.impl.R
@Composable
internal fun TangemPayChangePinCodeSuccessScreen(onClick: () -> Unit, modifier: Modifier = Modifier) {
val composition by rememberLottieComposition(spec = LottieCompositionSpec.RawRes(R.raw.anim_confetti))
val progress by animateLottieCompositionAsState(composition)
var showConfetti by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
TangemTopAppBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.common_done).resolveReference(),
startButton = null,
titleAlignment = Alignment.CenterHorizontally,
)
Column(
modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SuccessContent(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.navigationBarsPadding(),
text = stringResourceSafe(R.string.common_done),
onClick = onClick,
)
}
if (showConfetti) {
FullScreen(notTouchable = true) {
LottieAnimation(
composition = composition,
progress = { progress },
)
}
}
LaunchedEffect(Unit) {
showConfetti = true
}
LaunchedEffect(progress == 1f) {
if (progress == 1f) {
showConfetti = false
}
}
}
}
@Composable
private fun SuccessContent(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_success_blue_76),
tint = Color.Unspecified,
contentDescription = null,
modifier = Modifier
.size(76.dp),
)
SpacerH32()
Text(
modifier = Modifier.padding(horizontal = 32.dp),
text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
)
SpacerH12()
Text(
modifier = Modifier.padding(horizontal = 32.dp),
text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerH(72.dp)
}
}
@Preview(showBackground = true)
@Composable
private fun Preview() {
TangemThemePreview {
TangemPayChangePinCodeSuccessScreen(onClick = {})
}
}

View file

@ -0,0 +1,224 @@
package com.tangem.features.tangempay.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Color.Companion.Transparent
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
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.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayChangePinUM
import kotlinx.coroutines.delay
@Composable
internal fun TangemPayChangePinScreen(
state: TangemPayChangePinUM,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
onBackClick = onBackClick,
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 48.dp)
.padding(horizontal = 36.dp)
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.visa_onboarding_pin_code_title),
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
SpacerH16()
Text(
text = stringResourceSafe(R.string.visa_onboarding_pin_code_description),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
SpacerH(26.dp)
PinCodeSection(state)
}
NavigationPrimaryButton(
modifier = Modifier
.imePadding()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.fillMaxWidth(),
primaryButton = NavigationButton(
textReference = resourceReference(R.string.common_submit),
onClick = state.onSubmitClick,
shouldShowProgress = state.submitButtonLoading,
isEnabled = state.submitButtonEnabled,
),
)
}
}
@Composable
private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
PinCode(
modifier = modifier,
value = state.pinCode,
onValueChange = state.onPinCodeChange,
focusRequester = focusRequester,
)
AnimatedVisibility(
visible = state.error != null,
) {
val error = remember(this) { requireNotNull(state.error) }
Column {
SpacerH4()
Text(
text = error.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.warning,
textAlign = TextAlign.Center,
)
}
}
}
LaunchedEffect(Unit) {
delay(timeMillis = 300)
focusRequester.requestFocus()
}
}
@Composable
private fun PinCode(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
numbersCount: Int = 4,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
val keyboardController = LocalSoftwareKeyboardController.current
BasicTextField(
value = value,
onValueChange = { text ->
if (text.length <= numbersCount) {
onValueChange(text)
}
},
modifier = modifier
.focusRequester(focusRequester)
.clickable {
focusRequester.requestFocus()
keyboardController?.show()
},
textStyle = TangemTheme.typography.h1.copy(color = Transparent),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }),
cursorBrush = SolidColor(Transparent),
decorationBox = { innerTextField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(numbersCount) { index ->
val digit = value.getOrNull(index)?.toString()
PinDigitBox(
digit = digit,
backgroundColor = TangemTheme.colors.field.focused,
borderColor = TangemTheme.colors.stroke.primary,
textColor = TangemTheme.colors.text.primary1,
textStyle = TangemTheme.typography.h1,
)
}
}
innerTextField()
}
},
)
}
@Composable
private fun PinDigitBox(
digit: String?,
backgroundColor: Color,
borderColor: Color,
textColor: Color,
textStyle: TextStyle,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(width = 48.dp, height = 64.dp)
.background(
color = backgroundColor,
shape = RoundedCornerShape(12.dp),
)
.border(
width = 1.dp,
color = borderColor,
shape = RoundedCornerShape(12.dp),
),
contentAlignment = Alignment.Center,
) {
if (digit != null) {
Text(
text = digit,
style = textStyle,
color = textColor,
textAlign = TextAlign.Center,
)
}
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.tangempay.utils
internal object PinCodeValidation {
private const val PIN_CODE_LENGTH = 4
fun validate(pinCode: String): Boolean {
return sequenceOf(
::validateAllDigits,
::validateLength,
::validateNoRepeatedDigits,
::validateNoConsecutiveDigits,
).all { it(pinCode) }
}
fun validateLength(pinCode: String): Boolean {
return pinCode.length == PIN_CODE_LENGTH
}
private fun validateAllDigits(pinCode: String): Boolean {
return pinCode.all { it.isDigit() }
}
private fun validateNoRepeatedDigits(pinCode: String): Boolean {
return pinCode.toSet().size > 1
}
private fun validateNoConsecutiveDigits(pinCode: String): Boolean {
return pinCode.zipWithNext().any { it.second != it.first + 1 } &&
pinCode.zipWithNext().any { it.second != it.first - 1 }
}
}