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

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