Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-09 04:24:13 -07:00
parent bd411ae949
commit 49127d64dd
9 changed files with 603 additions and 0 deletions

View file

@ -1612,6 +1612,7 @@
<string name="tangempay_card_details_withdraw_error_title">Withdraw unavailable now</string>
<string name="tangempay_card_details_withdraw_in_progress_description">You can\'t initiate swap or new withdrawal till the current one is finished</string>
<string name="tangempay_card_details_withdraw_in_progress_title">Withdrawal in progress</string>
<string name="tangempay_card_page_settings_title">Card settings</string>
<string name="tangempay_change_pin_code">Change PIN-code</string>
<string name="tangempay_come_back_if_forget_pin">Come back to the app if you forget it.</string>
<string name="tangempay_factory_settings_warning_title">I understand that I will completely lose access to my Tangem Pay Card and all funds on it without the possibility of recovery</string>

View file

@ -0,0 +1,14 @@
package com.tangem.features.tangempay.components
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
interface TangemPayCardPageComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
val config: TangemPayDetailsConfig,
)
interface Factory : ComponentFactory<Params, TangemPayCardPageComponent>
}

View file

@ -0,0 +1,96 @@
package com.tangem.features.tangempay.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultTangemPayCardPageComponent @AssistedInject constructor(
@Assisted private val appComponentContext: AppComponentContext,
@Assisted private val params: TangemPayCardPageComponent.Params,
) : AppComponentContext by appComponentContext, TangemPayCardPageComponent {
private val stackNavigation = StackNavigation<TangemPayDetailsInnerRoute>()
private val innerRouter = InnerRouter<TangemPayDetailsInnerRoute>(
stackNavigation = stackNavigation,
popCallback = { onChildBack() },
)
private val childStack = childStack(
key = "tangemPayCardPageInnerStack",
source = stackNavigation,
serializer = TangemPayDetailsInnerRoute.serializer(),
initialConfiguration = TangemPayDetailsInnerRoute.Details,
childFactory = ::screenChild,
)
@Suppress("ReusedModifierInstance")
@Composable
override fun Content(modifier: Modifier) {
val childStack by childStack.subscribeAsState()
Children(
stack = childStack,
animation = stackAnimation(),
) { child ->
child.instance.Content(modifier = modifier)
}
}
private fun screenChild(
config: TangemPayDetailsInnerRoute,
componentContext: ComponentContext,
): ComposableContentComponent = when (config) {
TangemPayDetailsInnerRoute.Details -> TangemPayCardPageScreenComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = params,
)
TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayDetailsContainerComponent.Params(
userWalletId = params.userWalletId,
config = params.config,
),
)
TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
)
TangemPayDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayDetailsContainerComponent.Params(
userWalletId = params.userWalletId,
config = params.config,
),
)
}
private fun onChildBack() {
if (childStack.value.backStack.isEmpty()) {
router.pop()
} else {
stackNavigation.pop()
}
}
@AssistedFactory
interface Factory : TangemPayCardPageComponent.Factory {
override fun create(
context: AppComponentContext,
params: TangemPayCardPageComponent.Params,
): DefaultTangemPayCardPageComponent
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.tangempay.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
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.ui.components.NavigationBar3ButtonsScrim
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.model.TangemPayCardPageModel
import com.tangem.features.tangempay.ui.TangemPayCardPageScreen
internal class TangemPayCardPageScreenComponent(
private val appComponentContext: AppComponentContext,
private val params: TangemPayCardPageComponent.Params,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: TangemPayCardPageModel = getOrCreateModel(params = params)
private val containerParams = TangemPayDetailsContainerComponent.Params(
userWalletId = params.userWalletId,
config = params.config,
)
private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent(
appComponentContext = child("cardDetailsBlockComponent"),
params = TangemPayCardDetailsBlockComponent.Params(params = containerParams),
)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TangemPayDetailsNavigation.serializer(),
handleBackButton = false,
childFactory = ::bottomSheetChild,
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
NavigationBar3ButtonsScrim()
TangemPayCardPageScreen(
state = state,
cardDetailsBlockComponent = cardDetailsBlockComponent,
cardDetailsState = cardDetailsState,
modifier = modifier,
)
bottomSheet.child?.instance?.BottomSheet()
}
private fun bottomSheetChild(
navigation: TangemPayDetailsNavigation,
componentContext: ComponentContext,
): ComposableBottomSheetComponent {
val context = childByContext(componentContext)
return when (navigation) {
is TangemPayDetailsNavigation.ViewPinCode -> TangemPayViewPinComponent(
appComponentContext = context,
params = TangemPayViewPinComponent.Params(
walletId = navigation.userWalletId,
cardId = navigation.cardId,
listener = model,
),
)
else -> error("Unsupported bottom sheet navigation: $navigation")
}
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.features.tangempay.di
import com.tangem.features.tangempay.components.DefaultTangemPayCardPageComponent
import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayCardPageComponent
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.model.listener.CardDetailsEventListener
import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener
@ -20,6 +22,12 @@ internal interface TangemPayDetailsFeatureModule {
factory: DefaultTangemPayDetailsContainerComponent.Factory,
): TangemPayDetailsContainerComponent.Factory
@Binds
@Singleton
fun bindTangemPayCardPageComponentFactory(
factory: DefaultTangemPayCardPageComponent.Factory,
): TangemPayCardPageComponent.Factory
@Binds
@Singleton
fun bindCardDetailsEventListener(impl: DefaultCardDetailsEventListener): CardDetailsEventListener

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.tangempay.model.TangemPayAddFundsModel
import com.tangem.features.tangempay.model.TangemPayAddToWalletModel
import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel
import com.tangem.features.tangempay.model.TangemPayCardPageModel
import com.tangem.features.tangempay.model.TangemPayChangePinModel
import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel
@ -59,4 +60,9 @@ internal interface TangemPayModelModule {
@IntoMap
@ClassKey(TangemPayViewPinModel::class)
fun bindTangemPayViewPinModel(model: TangemPayViewPinModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayCardPageModel::class)
fun bindTangemPayCardPageModel(model: TangemPayCardPageModel): Model
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.tangempay.entity
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
internal data class TangemPayCardPageUM(
val addToWalletBlockState: AddToWalletBlockState? = null,
val settings: ImmutableList<TangemPayCardPageSetting> = persistentListOf(
TangemPayCardPageSetting.ChangePIN,
TangemPayCardPageSetting.FreezeCard,
),
val onBackClick: () -> Unit,
val onSettingClick: (TangemPayCardPageSetting) -> Unit,
) {
companion object {
fun stub(
addToWalletBlockState: AddToWalletBlockState? = AddToWalletBlockState(onClick = {}, onClickClose = {}),
settings: ImmutableList<TangemPayCardPageSetting> = persistentListOf(
TangemPayCardPageSetting.ChangePIN,
TangemPayCardPageSetting.FreezeCard,
TangemPayCardPageSetting.ReplaceCard,
),
) = TangemPayCardPageUM(
addToWalletBlockState = addToWalletBlockState,
settings = settings,
onBackClick = {},
onSettingClick = {},
)
}
}
@Immutable
internal sealed class TangemPayCardPageSetting {
data object ChangePIN : TangemPayCardPageSetting()
data object FreezeCard : TangemPayCardPageSetting()
data object ReplaceCard : TangemPayCardPageSetting()
}

View file

@ -0,0 +1,177 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
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.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.components.TangemPayCardPageComponent
import com.tangem.features.tangempay.components.ViewPinListener
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.AddToWalletBlockState
import com.tangem.features.tangempay.entity.TangemPayCardPageSetting
import com.tangem.features.tangempay.entity.TangemPayCardPageUM
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayCardPageModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val uiMessageSender: UiMessageSender,
) : Model(), ViewPinListener {
private val params: TangemPayCardPageComponent.Params = paramsContainer.require()
private var currentFrozenState: TangemPayCardFrozenState = params.config.cardFrozenState
private val addToWalletBannerJobHolder = JobHolder()
val uiState: StateFlow<TangemPayCardPageUM>
field = MutableStateFlow(
TangemPayCardPageUM(
onBackClick = router::pop,
onSettingClick = ::onSettingClick,
),
)
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
init {
fetchAddToWalletBanner()
subscribeToCardFrozenState()
}
private fun onSettingClick(setting: TangemPayCardPageSetting) = when (setting) {
TangemPayCardPageSetting.ChangePIN -> onClickChangePIN()
TangemPayCardPageSetting.FreezeCard -> onClickFreezeOrUnfreezeCard()
TangemPayCardPageSetting.ReplaceCard -> Unit // TODO v_rodionov #[REDACTED_TASK_KEY]
}
private fun onClickChangePIN() {
if (!params.config.isPinSet) {
router.push(TangemPayDetailsInnerRoute.ChangePIN)
} else {
bottomSheetNavigation.activate(
TangemPayDetailsNavigation.ViewPinCode(
userWalletId = params.userWalletId,
cardId = params.config.cardId,
),
)
}
}
private fun onClickFreezeOrUnfreezeCard() {
when (currentFrozenState) {
TangemPayCardFrozenState.Frozen -> uiMessageSender.send(
TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard),
)
else -> uiMessageSender.send(
TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard),
)
}
}
private fun freezeCard() {
modelScope.launch {
cardDetailsRepository.freezeCard(
userWalletId = params.userWalletId,
cardId = params.config.cardId,
).onLeft {
val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed))
uiMessageSender.send(message)
}.onRight { state ->
val message = if (state == TangemPayCardFrozenState.Frozen) {
SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success))
} else {
SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed))
}
uiMessageSender.send(message)
}
}
}
private fun unfreezeCard() {
modelScope.launch {
cardDetailsRepository.unfreezeCard(
userWalletId = params.userWalletId,
cardId = params.config.cardId,
).onLeft {
val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed))
uiMessageSender.send(message)
}.onRight { state ->
val message = if (state == TangemPayCardFrozenState.Unfrozen) {
SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success))
} else {
SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed))
}
uiMessageSender.send(message)
}
}
}
private fun subscribeToCardFrozenState() {
cardDetailsRepository
.cardFrozenState(params.config.cardId)
.onEach { state -> currentFrozenState = state }
.launchIn(modelScope)
}
private fun fetchAddToWalletBanner() {
modelScope.launch {
val isDone = cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true
if (!isDone) {
uiState.update { state ->
state.copy(
addToWalletBlockState = AddToWalletBlockState(
onClick = ::onClickAddToWallet,
onClickClose = ::onClickCloseBanner,
),
)
}
}
}.saveIn(addToWalletBannerJobHolder)
}
private fun onClickAddToWallet() {
router.push(TangemPayDetailsInnerRoute.AddToWallet)
}
private fun onClickCloseBanner() {
modelScope.launch {
cardDetailsRepository.setAddToWalletAsDone(params.userWalletId)
uiState.update { it.copy(addToWalletBlockState = null) }
}.saveIn(addToWalletBannerJobHolder)
}
override fun onClickChangePin() {
bottomSheetNavigation.dismiss()
router.push(TangemPayDetailsInnerRoute.ChangePIN)
}
override fun onDismissViewPin() {
bottomSheetNavigation.dismiss()
}
}

View file

@ -0,0 +1,182 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.entity.TangemPayCardPageSetting
import com.tangem.features.tangempay.entity.TangemPayCardPageUM
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun TangemPayCardPageScreen(
state: TangemPayCardPageUM,
cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent,
cardDetailsState: TangemPayCardDetailsUM,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
onBackClick = state.onBackClick,
)
},
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars),
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(scaffoldPaddings),
contentPadding = PaddingValues(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16 + bottomBarHeight,
),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
item(key = "Card") {
cardDetailsBlockComponent.CardDetailsBlockContent(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
state = cardDetailsState,
)
}
if (state.addToWalletBlockState != null) {
item(key = "GooglePay") {
TangemPayAddToWalletBlock(
state = state.addToWalletBlockState,
)
}
}
item(key = "Settings") {
TangemPayCardPageSettingsBlock(
settings = state.settings,
onSettingClick = state.onSettingClick,
)
}
}
}
}
@Composable
private fun TangemPayCardPageSettingsBlock(
settings: ImmutableList<TangemPayCardPageSetting>,
onSettingClick: (TangemPayCardPageSetting) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.primary,
shape = TangemTheme.shapes.roundedCornersMedium,
),
) {
Text(
modifier = Modifier.padding(
start = TangemTheme.dimens.spacing12,
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing4,
),
text = stringResourceSafe(R.string.tangempay_card_page_settings_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
settings.fastForEach { item ->
TangemPayCardPageSettingRow(
item = item,
onClick = { onSettingClick(item) },
)
}
}
}
@Composable
private fun TangemPayCardPageSettingRow(
item: TangemPayCardPageSetting,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(TangemTheme.dimens.spacing12),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = stringResourceSafe(item.titleRes),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
}
}
private val TangemPayCardPageSetting.titleRes
get() = when (this) {
TangemPayCardPageSetting.ChangePIN -> R.string.tangempay_card_details_change_pin
TangemPayCardPageSetting.FreezeCard -> R.string.tangempay_card_details_freeze_card
TangemPayCardPageSetting.ReplaceCard -> R.string.common_error // TODO v_rodionov #[REDACTED_TASK_KEY]
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun preview() = TangemThemePreview {
TangemPayCardPageScreen(
state = TangemPayCardPageUM.stub(),
cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent(
TangemPayCardDetailsUM(
number = "•••• •••• •••• 1245",
numberShort = "··1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
),
),
cardDetailsState = TangemPayCardDetailsUM(
number = "•••• •••• •••• 1245",
numberShort = "··1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
),
)
}