Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 21:39:52 +02:00
commit f66092d82c
52 changed files with 1448 additions and 464 deletions

View file

@ -200,6 +200,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
}
splashScreen.setKeepOnScreenCondition { viewModel.isSplashScreenShown }
splashScreen.setOnExitAnimationListener { provider -> provider.remove() }
installActivityDependencies()
observeAppThemeModeUpdates()

View file

@ -4,12 +4,7 @@ import android.app.Activity
import android.os.Build
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@ -32,11 +27,7 @@ import com.tangem.core.ui.components.haze.ProvideHaze
import com.tangem.core.ui.components.snackbar.TangemSnackbarHost
import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost
import com.tangem.core.ui.message.EventMessageEffect
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.LocalSnackbarHostState
import com.tangem.core.ui.res.LocalTopSnackbarHostState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.*
import com.tangem.core.ui.security.ProvideSecureFlagController
import com.tangem.tap.routing.component.RoutingComponent
import com.tangem.tap.routing.transitions.RoutingTransitionAnimationFactory
@ -124,20 +115,34 @@ private fun childrenAnimation(
backHandler: BackHandler,
onBack: () -> Unit,
): StackAnimation<AppRoute, RoutingComponent.Child> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val routeAnimation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
predictiveBackAnimation(
backHandler = backHandler,
onBack = onBack,
selector = { backEvent, _, _ ->
androidPredictiveBackAnimatable(backEvent)
},
fallbackAnimation = stackAnimation {
RoutingTransitionAnimationFactory.create(it.configuration)
fallbackAnimation = stackAnimation<AppRoute, RoutingComponent.Child> { child ->
RoutingTransitionAnimationFactory.create(child.configuration)
},
)
} else {
stackAnimation {
RoutingTransitionAnimationFactory.create(it.configuration)
stackAnimation { child ->
RoutingTransitionAnimationFactory.create(child.configuration)
}
}
return skipAnimationWhileInitial(routeAnimation)
}
private fun skipAnimationWhileInitial(
delegate: StackAnimation<AppRoute, RoutingComponent.Child>,
): StackAnimation<AppRoute, RoutingComponent.Child> = StackAnimation { stack, animModifier, content ->
if (stack.active.configuration is AppRoute.Initial) {
Box(modifier = animModifier) {
content(stack.active)
}
} else {
delegate(stack, animModifier, content)
}
}

View file

@ -3,6 +3,7 @@
<style name="BaseAppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar" />
<style name="AppTheme" parent="BaseAppTheme">
<item name="android:windowBackground">@color/background_secondary</item>
<item name="colorPrimary">@color/menu_accent_color</item>
<item name="colorSecondary">@color/menu_accent_color</item>
<item name="android:textColor">@color/text_primary_1</item>

View file

@ -125,15 +125,21 @@ private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Mo
.padding(end = TangemTheme.dimens2.x2),
)
val subtitle = state.subtitleUM
if (subtitle is EarnBlockUM.SubtitleUM.Text) {
EarnBlockSubtitle(
val subtitleModifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(end = TangemTheme.dimens2.x2)
when (val subtitle = state.subtitleUM) {
is EarnBlockUM.SubtitleUM.Text -> EarnBlockSubtitle(
subtitle = subtitle,
type = state.type,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(end = TangemTheme.dimens2.x2),
modifier = subtitleModifier,
)
is EarnBlockUM.SubtitleUM.AccentedText -> EarnBlockAccentedSubtitle(
subtitle = subtitle,
type = state.type,
modifier = subtitleModifier,
)
null -> Unit
}
EarnBlockTrailing(type = state.type, trailingUM = state.trailingUM, onClick = state.onClick)
@ -253,26 +259,24 @@ private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, o
)
}
is EarnBlockUM.TrailingUM.Balance -> {
if (!trailingUM.isBalanceHidden) {
val fiatModifier = Modifier.layoutId(TangemRowLayoutId.END_TOP).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_FIAT_AMOUNT) else it
}
val cryptoModifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) else it
}
Text(
text = trailingUM.fiatValue.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = fiatModifier,
)
Text(
text = trailingUM.cryptoValue.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
modifier = cryptoModifier,
)
val fiatModifier = Modifier.layoutId(TangemRowLayoutId.END_TOP).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_FIAT_AMOUNT) else it
}
val cryptoModifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) else it
}
Text(
text = trailingUM.fiatValue.orMaskWithStars(trailingUM.isBalanceHidden).resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = fiatModifier,
)
Text(
text = trailingUM.cryptoValue.orMaskWithStars(trailingUM.isBalanceHidden).resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
modifier = cryptoModifier,
)
}
null -> Unit
}
@ -325,6 +329,29 @@ private fun EarnBlockSubtitle(subtitle: EarnBlockUM.SubtitleUM.Text, type: Type,
}
}
@Composable
private fun EarnBlockAccentedSubtitle(
subtitle: EarnBlockUM.SubtitleUM.AccentedText,
type: Type,
modifier: Modifier = Modifier,
) {
val baseText = subtitle.text.resolveReference()
val accentText = subtitle.accent.resolveReference()
val accentColor = type.accentText()
Text(
text = buildAnnotatedString {
append(baseText)
if (baseText.isNotEmpty() && !baseText.last().isWhitespace()) append(' ')
withStyle(SpanStyle(color = accentColor)) {
append(accentText)
}
},
style = subtitle.style.textStyle,
color = TangemTheme.colors2.text.neutral.tertiary,
modifier = modifier,
)
}
@Composable
private fun EarnBlockIcon(type: Type, iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) {
Box(

View file

@ -72,6 +72,12 @@ sealed interface EarnBlockUM {
val loader: Loader? = null,
) : SubtitleUM
data class AccentedText(
val text: TextReference,
val accent: TextReference,
val style: Style,
) : SubtitleUM
data class Loader(val tone: LoaderTone)
enum class Style { Large, Small }

View file

@ -71,9 +71,11 @@ interface TangemPayApi {
@GET("v1/customer/balance")
suspend fun getCardBalance(@Header("Authorization") authHeader: String): ApiResponse<CardBalanceResponse>
@POST("v1/customer/card/details")
/** Card-scoped reveal. `{card_id}` = selected card id. */
@POST("v1/customer/card/{card_id}/details")
suspend fun revealCardDetails(
@Header("Authorization") authHeader: String,
@Path("card_id") cardId: String,
@Body body: CardDetailsRequest,
): ApiResponse<CardDetailsResponse>

View file

@ -1517,6 +1517,8 @@
<string name="staking_details_apy">APY</string>
<string name="staking_details_auto_claiming_rewards_daily_text">Rewards automatically accumulate in your staking balance daily.</string>
<string name="staking_details_autocompound_rewards_earned">Rewards are compounded to your staking balance. Funds earned: %s</string>
<string name="staking_details_autocompound_rewards_compounded">Rewards are compounded to your staking balance.</string>
<string name="staking_details_autocompound_funds_earned">Funds earned: %s</string>
<string name="staking_details_available">Available</string>
<string name="staking_details_average_reward_rate">Average Reward Rate</string>
<string name="staking_details_banner_text">How Staking Works?</string>

View file

@ -75,7 +75,10 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
)
}
override suspend fun revealCardDetails(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardDetails> {
override suspend fun revealCardDetails(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardDetails> {
return catch(
block = {
val publicKeyBase64 = getPublicKeyBase64()
@ -84,6 +87,7 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
requestHelper.performRequest(userWalletId = userWalletId) { authHeader ->
tangemPayApi.revealCardDetails(
authHeader = authHeader,
cardId = cardId,
body = CardDetailsRequest(sessionId = sessionId),
)
}.getOrNull()?.result,

View file

@ -35,6 +35,7 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor(
override suspend fun revealCardDetails(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardDetails> {
if (isMockMode) {
return TangemPayCardDetails(
@ -44,7 +45,7 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor(
expirationMonth = MOCK_EXPIRATION_MONTH,
).right()
}
return real.revealCardDetails(userWalletId)
return real.revealCardDetails(userWalletId, cardId)
}
override suspend fun getPin(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?> {

View file

@ -15,7 +15,10 @@ interface TangemPayCardDetailsRepository {
suspend fun getCardBalance(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardBalance>
suspend fun revealCardDetails(userWalletId: UserWalletId): Either<UniversalError, TangemPayCardDetails>
suspend fun revealCardDetails(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayCardDetails>
suspend fun getPin(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?>

View file

@ -274,13 +274,13 @@ sealed class TangemPayAnalyticsEvents(
event = "Visa Add Extra Card Clicked",
)
class FakeDoorPopupDisplayed : TangemPayAnalyticsEvents(
class IssueAdditionalCardPopupShown : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Fakedoor Popup Displayed",
event = "Visa Extra Card Issuance Popup Displayed",
)
class FakeDoorGotitClicked : TangemPayAnalyticsEvents(
class IssueAdditionalCardConfirmed : TangemPayAnalyticsEvents(
categoryName = "Visa Card Management",
event = "Visa Fakedoor Gotit Clicked",
event = "Visa Extra Card Issuance Confirmed",
)
}

View file

@ -65,9 +65,12 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
tokenReceiveComponentFactory = tokenReceiveComponentFactory,
expressTransactionsComponentFactory = expressTransactionsComponentFactory,
)
TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create(
is TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create(
context = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayCardPageComponent.Params(initialStatus = params.initialStatus),
params = TangemPayCardPageComponent.Params(
initialStatus = params.initialStatus,
cardId = config.cardId,
),
)
TangemPayAccountDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),

View file

@ -2,51 +2,42 @@ package com.tangem.features.tangempay.components
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.context.child
import com.tangem.core.decompose.model.getOrCreateModel
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.core.ui.res.LocalVisaRedesignEnabled
import com.tangem.features.tangempay.model.TangemPayAddToWalletModel
import com.tangem.features.tangempay.ui.TangemPayAddToWalletScreen
import com.tangem.features.tangempay.ui.TangemPayAddToWalletScreenV2
import com.tangem.features.tangempay.utils.userWalletId
internal class TangemPayAddToWalletComponent(
private val appComponentContext: AppComponentContext,
private val params: TangemPayDetailsContainerComponent.Params,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: TangemPayAddToWalletModel = getOrCreateModel()
private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent(
appComponentContext = child("cardDetailsBlockComponent"),
params = TangemPayCardDetailsBlockComponent.Params(
initialStatus = params.initialStatus,
userWalletId = params.initialStatus.userWalletId,
isEditingNameEnabled = false,
shouldShowCardDetailsButtonOnCard = true,
),
)
private val model: TangemPayAddToWalletModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val cardDetailsState by model.cardDetailsState.collectAsStateWithLifecycle()
BackHandler(onBack = router::pop)
if (model.isRedesignEnabled()) {
TangemPayAddToWalletScreenV2(
state = state,
cardDetailsBlockComponent = cardDetailsBlockComponent,
)
} else {
TangemPayAddToWalletScreen(
state = state,
cardDetailsBlockComponent = cardDetailsBlockComponent,
)
CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) {
if (model.isRedesignEnabled()) {
TangemPayAddToWalletScreenV2(
state = state,
cardDetailsState = cardDetailsState,
)
} else {
TangemPayAddToWalletScreen(
state = state,
cardDetailsState = cardDetailsState,
)
}
}
}
}

View file

@ -84,9 +84,12 @@ internal class TangemPayCardPageComponent @AssistedInject constructor(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus),
)
TangemPayCardDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent(
is TangemPayCardDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus),
params = TangemPayCardScopedParams(
initialStatus = params.initialStatus,
cardId = config.cardId,
),
)
TangemPayCardDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
@ -106,7 +109,10 @@ internal class TangemPayCardPageComponent @AssistedInject constructor(
}
}
data class Params(val initialStatus: AccountStatus.Payment)
data class Params(
val initialStatus: AccountStatus.Payment,
val cardId: String,
)
@AssistedFactory
interface Factory : ComponentFactory<Params, TangemPayCardPageComponent> {

View file

@ -10,7 +10,6 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
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
@ -18,12 +17,9 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalVisaRedesignEnabled
import com.tangem.features.tangempay.closure.TangemPayCloseCardComponent
import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.entity.TangemPayCardNavigation
import com.tangem.features.tangempay.model.TangemPayCardPageModel
import com.tangem.features.tangempay.ui.TangemPayCardPageScreen
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.features.tokenreceive.TokenReceiveComponent
@ -35,16 +31,6 @@ internal class TangemPayCardPageScreenComponent(
private val model: TangemPayCardPageModel = getOrCreateModel(params = params)
private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent(
appComponentContext = child("cardDetailsBlockComponent"),
params = TangemPayCardDetailsBlockComponent.Params(
initialStatus = params.initialStatus,
userWalletId = params.initialStatus.userWalletId,
isEditingNameEnabled = true,
shouldShowCardDetailsButtonOnCard = false,
),
)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = TangemPayCardNavigation.serializer(),
@ -55,15 +41,17 @@ internal class TangemPayCardPageScreenComponent(
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle()
val cardControllers by model.cardControllersState.collectAsStateWithLifecycle()
val selectedCardId by model.selectedCardIdState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) {
NavigationBar3ButtonsScrim()
TangemPayCardPageScreen(
state = state,
cardDetailsBlockComponent = cardDetailsBlockComponent,
cardDetailsState = cardDetailsState,
cardControllers = cardControllers,
selectedCardId = selectedCardId,
onCardSelect = model::onCardPageSelected,
modifier = modifier,
)
bottomSheet.child?.instance?.BottomSheet()
@ -89,7 +77,7 @@ internal class TangemPayCardPageScreenComponent(
params = TangemPayReissueCardComponent.Params(
listener = model,
userWalletId = params.initialStatus.userWalletId,
cardId = params.initialStatus.firstCard().id,
cardId = navigation.cardId,
),
)
is TangemPayCardNavigation.CloseCard -> TangemPayCloseCardComponent(

View file

@ -0,0 +1,12 @@
package com.tangem.features.tangempay.components
import com.tangem.domain.models.account.AccountStatus
/**
* Params for sub-screens that operate on one explicit card (e.g. rename) instead of implicitly
* resolving the first card. Carries the [cardId] selected on the card page.
*/
internal data class TangemPayCardScopedParams(
val initialStatus: AccountStatus.Payment,
val cardId: String,
)

View file

@ -128,6 +128,16 @@ internal class TangemPayDetailsComponent(
listener = model,
),
)
is TangemPayDetailsNavigation.IssueAdditionalCard -> TangemPayIssueAdditionalCardComponent(
appComponentContext = context,
params = TangemPayIssueAdditionalCardComponent.Params(
userWalletId = navigation.walletId,
feeAmount = navigation.feeAmount,
feeCurrency = navigation.feeCurrency,
fiatBalance = navigation.fiatBalance,
listener = model,
),
)
}
}
}

View file

@ -2,41 +2,29 @@ package com.tangem.features.tangempay.components
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.context.child
import com.tangem.core.decompose.model.getOrCreateModel
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.core.ui.res.LocalVisaRedesignEnabled
import com.tangem.features.tangempay.entity.DisplayNameState
import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel
import com.tangem.features.tangempay.ui.TangemPayEditDisplayNameScreen
import com.tangem.features.tangempay.utils.userWalletId
internal class TangemPayEditDisplayNameComponent(
private val appComponentContext: AppComponentContext,
params: TangemPayDetailsContainerComponent.Params,
params: TangemPayCardScopedParams,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val model: TangemPayEditDisplayNameModel = getOrCreateModel(params)
private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent(
appComponentContext = child("editDisplayNameCardDetails"),
params = TangemPayCardDetailsBlockComponent.Params(
initialStatus = params.initialStatus,
userWalletId = params.initialStatus.userWalletId,
isEditingNameEnabled = false,
shouldShowCardDetailsButtonOnCard = false,
),
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle()
val cardDetailsState by model.cardDetailsState.collectAsStateWithLifecycle()
val editingCardDetailsState = cardDetailsState.copy(
displayNameState = DisplayNameState.Editing(
displayName = state.editingValue.text,
@ -48,12 +36,13 @@ internal class TangemPayEditDisplayNameComponent(
),
)
BackHandler(onBack = state.onDismiss)
TangemPayEditDisplayNameScreen(
state = state,
cardDetailsBlockComponent = cardDetailsBlockComponent,
cardDetailsState = editingCardDetailsState,
modifier = modifier,
isRedesignEnabled = model.isRedesignEnabled(),
)
CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) {
TangemPayEditDisplayNameScreen(
state = state,
cardDetailsState = editingCardDetailsState,
modifier = modifier,
isRedesignEnabled = model.isRedesignEnabled(),
)
}
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.tangempay.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.ComposableBottomSheetComponent
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.serialization.SerializedCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.model.TangemPayIssueAdditionalCardModel
import com.tangem.features.tangempay.ui.TangemPayIssueAdditionalCardContent
internal class TangemPayIssueAdditionalCardComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: TangemPayIssueAdditionalCardModel = getOrCreateModel(params = params)
override fun dismiss() {
model.onDismiss()
}
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
TangemPayIssueAdditionalCardContent(state = state)
}
data class Params(
val userWalletId: UserWalletId,
val feeAmount: SerializedBigDecimal,
val feeCurrency: SerializedCurrency,
val fiatBalance: SerializedBigDecimal,
val listener: Listener,
)
interface Listener {
fun onIssueAdditionalCardDismissed()
fun onIssueAdditionalCardSucceeded()
fun onAddFundsForCardIssue()
}
}

View file

@ -1,28 +0,0 @@
package com.tangem.features.tangempay.components.cardDetails
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.res.LocalVisaRedesignEnabled
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel
import com.tangem.features.tangempay.ui.TangemPayCard
import kotlinx.coroutines.flow.StateFlow
internal class DefaultTangemPayCardDetailsBlockComponent(
appComponentContext: AppComponentContext,
params: TangemPayCardDetailsBlockComponent.Params,
) : AppComponentContext by appComponentContext, TangemPayCardDetailsBlockComponent {
private val model: TangemPayCardDetailsBlockModel = getOrCreateModel(params = params)
override val state: StateFlow<TangemPayCardDetailsUM> = model.uiState
@Composable
override fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) {
CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) {
TangemPayCard(state, modifier)
}
}
}

View file

@ -1,20 +0,0 @@
package com.tangem.features.tangempay.components.cardDetails
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.ui.TangemPayCard
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
internal class PreviewTangemPayCardDetailsBlockComponent(
state: TangemPayCardDetailsUM,
) : TangemPayCardDetailsBlockComponent {
override val state: StateFlow<TangemPayCardDetailsUM> = MutableStateFlow(state)
@Composable
override fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) {
TangemPayCard(state, modifier)
}
}

View file

@ -1,24 +0,0 @@
package com.tangem.features.tangempay.components.cardDetails
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import kotlinx.coroutines.flow.StateFlow
@Stable
internal interface TangemPayCardDetailsBlockComponent {
val state: StateFlow<TangemPayCardDetailsUM>
@Composable
fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier)
data class Params(
val initialStatus: AccountStatus.Payment,
val userWalletId: UserWalletId,
val isEditingNameEnabled: Boolean,
val shouldShowCardDetailsButtonOnCard: Boolean,
)
}

View file

@ -2,19 +2,9 @@ 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.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.closure.TangemPayCloseCardModel
import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupModel
import com.tangem.features.tangempay.model.TangemPayChangePinModel
import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel
import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel
import com.tangem.features.tangempay.model.TangemPayTxHistoryModel
import com.tangem.features.tangempay.model.TangemPayReissueCardModel
import com.tangem.features.tangempay.model.TangemPayViewPinModel
import com.tangem.features.tangempay.model.*
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -45,11 +35,6 @@ internal interface TangemPayModelModule {
@ClassKey(TangemPayChangePinModel::class)
fun bindTangemPayChangePinModel(model: TangemPayChangePinModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayCardDetailsBlockModel::class)
fun bindTangemPayCardDetailsBlockModel(model: TangemPayCardDetailsBlockModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayAddToWalletModel::class)
@ -75,6 +60,11 @@ internal interface TangemPayModelModule {
@ClassKey(TangemPayEditDisplayNameModel::class)
fun bindTangemPayEditDisplayNameModel(model: TangemPayEditDisplayNameModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayIssueAdditionalCardModel::class)
fun bindTangemPayIssueAdditionalCardModel(model: TangemPayIssueAdditionalCardModel): Model
@Binds
@IntoMap
@ClassKey(TangemPayReissueCardModel::class)

View file

@ -4,7 +4,6 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.model.CardDataType
import com.tangem.utils.StringsSigns
@Suppress("LongParameterList")

View file

@ -15,7 +15,7 @@ internal sealed class TangemPayCardNavigation {
) : TangemPayCardNavigation()
@Serializable
data object ReissueCard : TangemPayCardNavigation()
data class ReissueCard(val cardId: String) : TangemPayCardNavigation()
@Serializable
data class CloseCard(

View file

@ -3,6 +3,7 @@ package com.tangem.features.tangempay.entity
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.serialization.SerializedCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import kotlinx.serialization.Serializable
@ -27,4 +28,12 @@ internal sealed class TangemPayDetailsNavigation {
val transaction: TangemPayTxHistoryItem,
val isBalanceHidden: Boolean,
) : TangemPayDetailsNavigation()
@Serializable
data class IssueAdditionalCard(
val walletId: UserWalletId,
val feeAmount: SerializedBigDecimal,
val feeCurrency: SerializedCurrency,
val fiatBalance: SerializedBigDecimal,
) : TangemPayDetailsNavigation()
}

View file

@ -28,6 +28,7 @@ internal class TangemPayDetailsStateFactory(
private val intents: TangemPayDetailIntents,
private val isRedesignEnabled: Boolean,
private val isRemoveAccountEnabled: Boolean,
private val isMultipleCardsEnabled: Boolean,
) {
fun getLoadingState(): TangemPayDetailsUM {
return TangemPayDetailsUM(
@ -79,17 +80,18 @@ internal class TangemPayDetailsStateFactory(
(card == null || card.frozenState == TangemPayCardFrozenState.Unfrozen),
),
cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState(
cards = card?.let {
persistentListOf(
cards = status.cards
.let { if (isMultipleCardsEnabled) it else it.take(1) }
.map { cardItem ->
TangemPayDetailsBalanceBlockState.Card(
lastDigits = card.lastDigits,
onClick = intents::onCardClick,
isReissuing = card.state != TangemPayCardState.Active,
lastDigits = cardItem.lastDigits,
onClick = { intents.onCardClick(cardItem.id) },
isReissuing = cardItem.state != TangemPayCardState.Active,
isEnabled = errorNotificationConfig == null,
isFrozen = card.isFrozen,
),
)
} ?: persistentListOf(),
isFrozen = cardItem.isFrozen,
)
}
.toImmutableList(),
onAddCardClick = intents::onAddCardClick,
),
),

View file

@ -7,9 +7,12 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.features.tangempay.model.CardDataType
import kotlinx.collections.immutable.ImmutableList
internal enum class CardDataType {
Number, Expiry, CVV
}
internal data class TangemPayDetailsUM(
val topBarConfig: TangemPayDetailsTopBarConfig,
val pullToRefreshConfig: PullToRefreshConfig,

View file

@ -0,0 +1,10 @@
package com.tangem.features.tangempay.entity
internal data class TangemPayIssueAdditionalCardUM(
val isBalanceInsufficient: Boolean,
val feeText: String,
val isLoading: Boolean,
val onIssueClick: () -> Unit,
val onAddFundsClick: () -> Unit,
val onDismiss: () -> Unit,
)

View file

@ -3,13 +3,19 @@ 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.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayAddToWalletStepItemUM
import com.tangem.features.tangempay.entity.TangemPayAddToWalletUM
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController
import com.tangem.features.tangempay.utils.GoogleWalletUtil
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
@ -19,12 +25,29 @@ import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayAddToWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val googleWalletUtil: GoogleWalletUtil,
private val featureToggles: TangemPayFeatureToggles,
cardDetailsControllerFactory: TangemPayCardDetailsController.Factory,
) : Model() {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
private val cardDetailsController = cardDetailsControllerFactory.create(
scope = modelScope,
initialCard = params.initialStatus.firstCard(),
userWalletId = params.initialStatus.userWalletId,
config = TangemPayCardDetailsController.Config(
isEditingNameEnabled = false,
shouldShowCardDetailsButtonOnCard = true,
),
onEditNameClick = {},
)
val cardDetailsState: StateFlow<TangemPayCardDetailsUM> = cardDetailsController.uiState
val uiState: StateFlow<TangemPayAddToWalletUM>
field = MutableStateFlow(getInitialState())

View file

@ -28,6 +28,7 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.findCardWithId
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.pay.TangemPayCardState
@ -46,16 +47,23 @@ 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.*
import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController
import com.tangem.features.tangempay.model.listener.CardDetailsEvent
import com.tangem.features.tangempay.model.listener.CardDetailsEventListener
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
import com.tangem.features.tangempay.utils.*
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.features.tangempay.utils.cryptoCurrency
import com.tangem.features.tangempay.utils.ifLoadedOrNull
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -76,6 +84,7 @@ internal class TangemPayCardPageModel @Inject constructor(
private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase,
private val cardDetailsEventListener: CardDetailsEventListener,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val cardDetailsControllerFactory: TangemPayCardDetailsController.Factory,
) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener, CloseCardListener {
private val params: TangemPayCardPageComponent.Params = paramsContainer.require()
@ -86,12 +95,27 @@ internal class TangemPayCardPageModel @Inject constructor(
private val reloadLimitsJobHolder = JobHolder()
private val currentStatus = MutableStateFlow(params.initialStatus)
private val initialCardId = params.initialStatus.firstCard().id
private val userWalletId = currentStatus.value.userWalletId
/**
* Currently focused card in the swipe pager. Per-card actions and the "Details" reveal target it.
* Initialized to the card the user tapped on the account screen ([Params.cardId]); falls back to
* the first available card if it is gone (handled in [syncCardControllers]).
*/
private val selectedCardId = MutableStateFlow(params.cardId)
val selectedCardIdState: StateFlow<String> = selectedCardId
private val cardControllers = linkedMapOf<String, TangemPayCardDetailsController>()
private val _cardControllersState: MutableStateFlow<ImmutableList<TangemPayCardDetailsController>> =
MutableStateFlow(persistentListOf())
val cardControllersState: StateFlow<ImmutableList<TangemPayCardDetailsController>> = _cardControllersState
private val cryptoCurrency
get() = currentStatus.value.cryptoCurrency
// Multiple cards are temporarily gated behind the same toggle as close-card.
private val isMultipleCardsEnabled: Boolean get() = tangemPayFeatureToggles.isCloseCardEnabled
val uiState: StateFlow<TangemPayCardPageUM>
field = MutableStateFlow(
TangemPayCardPageUM(
@ -115,28 +139,24 @@ internal class TangemPayCardPageModel @Inject constructor(
paymentAccountStatusSupplier.invoke(userWalletId)
.onEach { state ->
currentStatus.update { state }
uiState.update { it.copy(dailyLimitState = buildDailyLimitState(state)) }
val status = state.value
if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) {
val card = state.findCard(initialCardId, params.initialStatus) ?: return@onEach
uiState.update { uiState ->
uiState.copy(
settings = buildSettings(card),
settingsV2 = buildSettingsV2(card),
menuItems = buildMenuItems(isLastCard = status.cards.isLastCard()),
cardState = card.state,
)
}
}
syncCardControllers(state)
}
.launchIn(modelScope)
combine(currentStatus, selectedCardId) { status, selectedId -> status to selectedId }
.onEach { (status, selectedId) -> updateSelectedCardUi(status, selectedId) }
.launchIn(modelScope)
}
override fun onDestroy() {
cardDetailsEventListener.send(CardDetailsEvent.HideAll)
super.onDestroy()
}
private fun buildDailyLimitState(state: AccountStatus.Payment): TangemPayDailyLimitBlockState {
val status = state.value
val card = if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) {
state.findCard(initialCardId, params.initialStatus)
status.findCardWithId(selectedCardId.value)
} else {
null
}
@ -157,6 +177,73 @@ internal class TangemPayCardPageModel @Inject constructor(
fun isRedesignEnabled(): Boolean = tangemPayFeatureToggles.isRedesignEnabled
/** Reports the card the user swiped to so per-card UI and reveal target it. */
fun onCardPageSelected(index: Int) {
cardControllersState.value.getOrNull(index)?.let { selectedCardId.value = it.cardId }
}
private fun childCardScope(): CoroutineScope =
CoroutineScope(modelScope.coroutineContext + SupervisorJob(modelScope.coroutineContext[Job]))
private fun syncCardControllers(state: AccountStatus.Payment) {
val status = state.value
if (status !is PaymentAccountStatusValue.Loaded || status.source != StatusSource.ACTUAL) return
val cards = status.cards.let { if (isMultipleCardsEnabled) it else it.take(1) }
val newIds = cards.mapTo(mutableSetOf()) { it.id }
cardControllers.keys.filterNot { it in newIds }.toList().forEach { removedId ->
cardControllers.remove(removedId)?.let { controller ->
controller.dispose()
cardDetailsEventListener.send(CardDetailsEvent.Hide(removedId))
}
}
val ordered = LinkedHashMap<String, TangemPayCardDetailsController>(cards.size)
cards.forEach { card ->
ordered[card.id] = cardControllers[card.id] ?: cardDetailsControllerFactory.create(
scope = childCardScope(),
initialCard = card,
userWalletId = userWalletId,
config = TangemPayCardDetailsController.Config(
isEditingNameEnabled = true,
shouldShowCardDetailsButtonOnCard = false,
),
onEditNameClick = { router.push(TangemPayCardDetailsInnerRoute.EditCardDisplayName(card.id)) },
)
}
cardControllers.clear()
cardControllers.putAll(ordered)
_cardControllersState.value = ordered.values.toList().toImmutableList()
if (selectedCardId.value !in newIds) {
ordered.keys.firstOrNull()?.let { selectedCardId.value = it }
}
}
private fun updateSelectedCardUi(state: AccountStatus.Payment, selectedId: String) {
val status = state.value
if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) {
val card = status.findCardWithId(selectedId) ?: return
uiState.update { uiState ->
uiState.copy(
dailyLimitState = buildDailyLimitState(state),
settings = buildSettings(card),
settingsV2 = buildSettingsV2(card),
menuItems = buildMenuItems(isLastCard = status.cards.isLastCard()),
cardState = card.state,
)
}
} else {
uiState.update { it.copy(dailyLimitState = buildDailyLimitState(state)) }
}
}
private fun selectedCard(): TangemPayCard? {
val status = currentStatus.value.value
return (status as? PaymentAccountStatusValue.Loaded)?.findCardWithId(selectedCardId.value)
}
private fun buildSettings(card: TangemPayCard): ImmutableList<TangemPayCardPageSetting> {
if (isRedesignEnabled()) return persistentListOf()
return persistentListOf(
@ -185,8 +272,9 @@ internal class TangemPayCardPageModel @Inject constructor(
private suspend fun subscribeOnDetailsState() {
if (!isRedesignEnabled()) return
cardDetailsEventListener.event.collect { event ->
val isDetailsShown = event == CardDetailsEvent.Show
combine(cardDetailsEventListener.event, selectedCardId) { event, selectedId ->
event is CardDetailsEvent.Show && event.cardId == selectedId
}.collect { isDetailsShown ->
uiState.update { state ->
state.copy(
settingsV2 = state.settingsV2
@ -277,9 +365,7 @@ internal class TangemPayCardPageModel @Inject constructor(
}
private fun onClickViewDetails() {
modelScope.launch(dispatchers.default) {
cardDetailsEventListener.send(CardDetailsEvent.Show)
}
cardDetailsEventListener.send(CardDetailsEvent.Show(selectedCardId.value))
}
private fun onClickReloadLimits() {
@ -300,7 +386,7 @@ internal class TangemPayCardPageModel @Inject constructor(
if (!isPinSet) {
router.push(TangemPayCardDetailsInnerRoute.ChangePIN)
} else {
val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return
val card = selectedCard() ?: return
bottomSheetNavigation.activate(
TangemPayCardNavigation.ViewPinCode(
userWalletId = userWalletId,
@ -329,7 +415,7 @@ internal class TangemPayCardPageModel @Inject constructor(
private fun onClickReissueCard() {
analytics.send(TangemPayAnalyticsEvents.ReplaceCardClicked())
bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard)
bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard(cardId = selectedCardId.value))
}
override fun onDismissReissueCard() {
@ -342,7 +428,7 @@ internal class TangemPayCardPageModel @Inject constructor(
private fun onClickCloseCard() {
analytics.send(TangemPayAnalyticsEvents.CloseCardClicked())
val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return
val card = selectedCard() ?: return
bottomSheetNavigation.activate(
TangemPayCardNavigation.CloseCard(
userWalletId = userWalletId,
@ -407,7 +493,7 @@ internal class TangemPayCardPageModel @Inject constructor(
}
private fun freezeCard() {
val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return
val card = selectedCard() ?: return
modelScope.launch {
changeCardFrozenStateUseCase(
userWalletId = userWalletId,
@ -424,7 +510,7 @@ internal class TangemPayCardPageModel @Inject constructor(
}
private fun unfreezeCard() {
val card = currentStatus.value.findCard(initialCardId, params.initialStatus) ?: return
val card = selectedCard() ?: return
modelScope.launch {
changeCardFrozenStateUseCase(
userWalletId = userWalletId,

View file

@ -32,6 +32,7 @@ import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.pay.usecase.GetCustomerOffersUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
@ -39,6 +40,7 @@ import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.AddFundsListener
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayIssueAdditionalCardComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
@ -56,6 +58,7 @@ import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.transformer.update
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -79,7 +82,12 @@ internal class TangemPayDetailsModel @Inject constructor(
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val produceTangemPayInitialDataUseCase: ProduceTangemPayInitialDataUseCase,
private val onboardingRepository: OnboardingRepository,
) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener {
private val getCustomerOffers: GetCustomerOffersUseCase,
) : Model(),
TangemPayTxHistoryUiActions,
TangemPayDetailIntents,
AddFundsListener,
TangemPayIssueAdditionalCardComponent.Listener {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
@ -91,12 +99,16 @@ internal class TangemPayDetailsModel @Inject constructor(
val cryptoCurrency
get() = currentStatus.value.cryptoCurrency
// Multiple cards are temporarily gated behind the same toggle as close-card.
private val isMultipleCardsEnabled: Boolean get() = tangemPayFeatureToggles.isCloseCardEnabled
private val stateFactory = TangemPayDetailsStateFactory(
onBack = router::pop,
onOpenMenu = ::onOpenMenu,
intents = this,
isRedesignEnabled = isRedesignEnabled(),
isRemoveAccountEnabled = tangemPayFeatureToggles.isRemoveAccountEnabled,
isMultipleCardsEnabled = isMultipleCardsEnabled,
)
val uiState: StateFlow<TangemPayDetailsUM>
@ -349,19 +361,54 @@ internal class TangemPayDetailsModel @Inject constructor(
urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK)
}
override fun onCardClick() {
override fun onCardClick(cardId: String) {
analytics.send(TangemPayAnalyticsEvents.CardIconClicked())
router.push(TangemPayAccountDetailsInnerRoute.CardDetails)
router.push(TangemPayAccountDetailsInnerRoute.CardDetails(cardId = cardId))
}
override fun onAddCardClick() {
analytics.send(TangemPayAnalyticsEvents.AddExtraCardClicked())
analytics.send(TangemPayAnalyticsEvents.FakeDoorPopupDisplayed())
uiMessageSender.send(
message = TangemPayMessagesFactory.createFutureFeature(
onGotItClick = { analytics.send(TangemPayAnalyticsEvents.FakeDoorGotitClicked()) },
),
)
if (!isMultipleCardsEnabled) {
uiMessageSender.send(TangemPayMessagesFactory.createFutureFeature(onGotItClick = {}))
return
}
val activeCardsCount = currentStatus.value.ifLoadedOrNull { loaded ->
loaded.cards.count { it.cardStatus.isActive }
} ?: 0
if (activeCardsCount >= MAX_ACTIVE_CARDS) {
uiMessageSender.send(TangemPayMessagesFactory.createMaximumCardsIssued(maxCards = MAX_ACTIVE_CARDS))
return
}
modelScope.launch {
val offer = getCustomerOffers.additionalCardOffer(userWalletId).getOrNull()
if (offer == null) {
uiMessageSender.send(message = TangemPayMessagesFactory.createGenericError())
return@launch
}
analytics.send(TangemPayAnalyticsEvents.IssueAdditionalCardPopupShown())
bottomSheetNavigation.activate(
TangemPayDetailsNavigation.IssueAdditionalCard(
walletId = userWalletId,
feeAmount = offer.fee.amount,
feeCurrency = offer.fee.currency,
fiatBalance = currentStatus.value.balanceOrNull()?.fiatBalance?.availableBalance ?: BigDecimal.ZERO,
),
)
}
}
override fun onIssueAdditionalCardDismissed() {
bottomSheetNavigation.dismiss()
}
override fun onIssueAdditionalCardSucceeded() {
bottomSheetNavigation.dismiss()
modelScope.launch { paymentAccountStatusFetcher.invoke(userWalletId) }
}
override fun onAddFundsForCardIssue() {
bottomSheetNavigation.dismiss()
onClickAddFunds()
}
override fun onRenewSession() {
@ -412,4 +459,8 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun showBottomSheetError(type: TangemPayDetailsErrorType) {
uiMessageSender.send(message = TangemPayMessagesFactory.createErrorMessage(errorType = type))
}
private companion object {
const val MAX_ACTIVE_CARDS = 3
}
}

View file

@ -19,10 +19,12 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayCardScopedParams
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController
import com.tangem.features.tangempay.utils.requireLoaded
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
@ -40,13 +42,27 @@ internal class TangemPayEditDisplayNameModel @Inject constructor(
private val uiMessageSender: UiMessageSender,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val featureToggles: TangemPayFeatureToggles,
cardDetailsControllerFactory: TangemPayCardDetailsController.Factory,
) : Model() {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
private val params: TangemPayCardScopedParams = paramsContainer.require()
private val card = params.initialStatus.firstCard()
private val card = params.initialStatus.requireLoaded().requireCardWithId(params.cardId)
private val originalDisplayName = card.displayName?.value.orEmpty()
private val cardDetailsController = cardDetailsControllerFactory.create(
scope = modelScope,
initialCard = card,
userWalletId = params.initialStatus.userWalletId,
config = TangemPayCardDetailsController.Config(
isEditingNameEnabled = false,
shouldShowCardDetailsButtonOnCard = false,
),
onEditNameClick = {},
)
val cardDetailsState: StateFlow<TangemPayCardDetailsUM> = cardDetailsController.uiState
val uiState: StateFlow<TangemPayEditDisplayNameUM>
field = MutableStateFlow(
TangemPayEditDisplayNameUM(

View file

@ -0,0 +1,86 @@
package com.tangem.features.tangempay.model
import androidx.compose.runtime.Stable
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.ui.UiMessageSender
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.pay.usecase.IssueAdditionalCardUseCase
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.components.TangemPayIssueAdditionalCardComponent
import com.tangem.features.tangempay.entity.TangemPayIssueAdditionalCardUM
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Stable
@ModelScoped
internal class TangemPayIssueAdditionalCardModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val issueAdditionalCard: IssueAdditionalCardUseCase,
private val uiMessageSender: UiMessageSender,
private val analytics: AnalyticsEventHandler,
) : Model() {
private val params: TangemPayIssueAdditionalCardComponent.Params = paramsContainer.require()
val uiState: StateFlow<TangemPayIssueAdditionalCardUM>
field = MutableStateFlow(buildInitialState())
private fun buildInitialState(): TangemPayIssueAdditionalCardUM {
return TangemPayIssueAdditionalCardUM(
isBalanceInsufficient = params.fiatBalance < params.feeAmount,
feeText = formatFee(),
isLoading = false,
onIssueClick = ::onIssueClick,
onAddFundsClick = { params.listener.onAddFundsForCardIssue() },
onDismiss = ::onDismiss,
)
}
fun onDismiss() {
params.listener.onIssueAdditionalCardDismissed()
}
private fun onIssueClick() {
analytics.send(TangemPayAnalyticsEvents.IssueAdditionalCardConfirmed())
uiState.update { it.copy(isLoading = true) }
modelScope.launch {
issueAdditionalCard(userWalletId = params.userWalletId).fold(
ifLeft = ::handleIssueError,
ifRight = {
uiState.update { uiState -> uiState.copy(isLoading = false) }
params.listener.onIssueAdditionalCardSucceeded()
},
)
}
}
private fun handleIssueError(error: VisaApiError) {
when (error) {
VisaApiError.CardIssueInsufficientBalance -> uiState.update { it.copy(isBalanceInsufficient = true) }
else -> {
uiMessageSender.send(message = TangemPayMessagesFactory.createGenericError())
onDismiss()
}
}
}
private fun formatFee(): String {
return params.feeAmount.format {
fiat(
fiatCurrencyCode = params.feeCurrency.currencyCode,
fiatCurrencySymbol = params.feeCurrency.symbol,
)
}
}
}

View file

@ -1,25 +1,23 @@
package com.tangem.features.tangempay.model
package com.tangem.features.tangempay.model.controller
import androidx.compose.runtime.Stable
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.extensions.TextReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.findCardWithId
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.CardDataType
import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.model.listener.CardDetailsEvent
@ -28,51 +26,61 @@ import com.tangem.features.tangempay.model.transformers.DetailsHiddenStateTransf
import com.tangem.features.tangempay.model.transformers.DetailsRevealProgressStateTransformer
import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTransformer
import com.tangem.features.tangempay.model.transformers.TangemPayCardDetailsUpdateNameTransformer
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
import com.tangem.features.tangempay.utils.findCard
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.utils.StringsSigns
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.transformer.update
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import com.tangem.utils.transformer.update as transformerUpdate
private const val SHOW_DETAILS_TIME = 30_000L
/**
* Owns the UI state of a single TangemPay card-detail block (the flip card with reveal PAN/CVV,
* freeze badge, display-name editing). Several controllers can be alive at once e.g. one per card
* in a swipe pager so all logic is scoped to [cardId] and reveal/hide is coordinated through the
* card-scoped [CardDetailsEventListener].
*
* lifecycle is owned by the host model, which passes a child [scope] and calls [dispose] when the
* card disappears.
*/
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class TangemPayCardDetailsBlockModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
internal class TangemPayCardDetailsController @AssistedInject constructor(
@Assisted private val scope: CoroutineScope,
@Assisted private val initialCard: TangemPayCard,
@Assisted private val userWalletId: UserWalletId,
@Assisted private val config: Config,
@Assisted private val onEditNameClick: () -> Unit,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val clipboardManager: ClipboardManager,
private val uiMessageSender: UiMessageSender,
private val cardDetailsEventListener: CardDetailsEventListener,
private val analytics: AnalyticsEventHandler,
private val router: Router,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val payFeatureToggles: TangemPayFeatureToggles,
) : Model() {
) {
val cardId: String = initialCard.id
private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require()
private val initialCard = params.initialStatus.firstCard()
private var frozenStateJob: Job? = null
private val stateFactory = TangemPayCardDetailsBlockStateFactory(
cardNumberEnd = initialCard.lastDigits,
displayName = initialCard.displayName,
isEditingNameEnabled = params.isEditingNameEnabled,
onEditNameClick = ::startEditingDisplayName,
isEditingNameEnabled = config.isEditingNameEnabled,
onEditNameClick = onEditNameClick,
onReveal = ::requestReveal,
onCopy = ::copyData,
shouldShowCardDetailsButtonOnCard = params.shouldShowCardDetailsButtonOnCard,
shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard,
)
val uiState: StateFlow<TangemPayCardDetailsUM>
@ -83,24 +91,27 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
init {
subscribeToCardChanges()
modelScope.launch {
cardDetailsEventListener.event.collectLatest { event ->
scope.launch {
cardDetailsEventListener.event.collect { event ->
when (event) {
CardDetailsEvent.Hide -> hideCardDetails()
CardDetailsEvent.Show -> revealCardDetails()
is CardDetailsEvent.Show -> if (event.cardId == cardId) revealCardDetails() else hideCardDetails()
is CardDetailsEvent.Hide -> if (event.cardId == cardId) hideCardDetails()
CardDetailsEvent.HideAll -> hideCardDetails()
}
}
}
}
fun isRedesignEnabled(): Boolean = payFeatureToggles.isRedesignEnabled
fun dispose() {
scope.cancel()
}
private fun subscribeToCardChanges() {
paymentAccountStatusSupplier.invoke(params.userWalletId)
paymentAccountStatusSupplier.invoke(userWalletId)
.onEach { state ->
val status = state.value
if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL) {
val card = state.findCard(initialCard.id, params.initialStatus) ?: return@onEach
val card = status.findCardWithId(cardId) ?: return@onEach
if (card.state != TangemPayCardState.Active) {
requestHide()
}
@ -115,7 +126,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
subscribeToCardFrozenState(card.id)
}
}
.launchIn(modelScope)
.launchIn(scope)
}
private fun subscribeToCardFrozenState(cardId: String) {
@ -126,24 +137,24 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
uiState.update { state -> state.copy(cardFrozenState = TangemPayCardFrozenState.Pending) }
}
}
.launchIn(modelScope)
.launchIn(scope)
}
private fun requestReveal() {
modelScope.launch { cardDetailsEventListener.send(CardDetailsEvent.Show) }
cardDetailsEventListener.send(CardDetailsEvent.Show(cardId))
}
private fun requestHide() {
modelScope.launch { cardDetailsEventListener.send(CardDetailsEvent.Hide) }
cardDetailsEventListener.send(CardDetailsEvent.Hide(cardId))
}
private fun revealCardDetails() {
analytics.send(TangemPayAnalyticsEvents.ViewCardDetailsClicked())
modelScope.launch {
scope.launch {
uiState.transformerUpdate(
transformer = DetailsRevealProgressStateTransformer(onClickHide = ::requestHide),
)
cardDetailsRepository.revealCardDetails(params.userWalletId)
cardDetailsRepository.revealCardDetails(userWalletId, cardId)
.onRight { cardDetails ->
uiState.transformerUpdate(
transformer = DetailsRevealedStateTransformer(
@ -157,7 +168,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
uiState.transformerUpdate(
transformer = DetailsHiddenStateTransformer(
stateFactory = stateFactory,
shouldShowCardDetailsButtonOnCard = params.shouldShowCardDetailsButtonOnCard,
shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard,
),
)
showError()
@ -166,7 +177,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
}
private fun launchShowDetailsTimer() {
modelScope.launch {
scope.launch {
delay(SHOW_DETAILS_TIME)
requestHide()
}.saveIn(showCardDetailsTimerJobHolder)
@ -177,7 +188,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
uiState.transformerUpdate(
transformer = DetailsHiddenStateTransformer(
stateFactory = stateFactory,
shouldShowCardDetailsButtonOnCard = params.shouldShowCardDetailsButtonOnCard,
shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard,
),
)
}
@ -188,10 +199,6 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
)
}
private fun startEditingDisplayName() {
router.push(TangemPayCardDetailsInnerRoute.EditCardDisplayName)
}
private fun copyData(text: String, type: CardDataType) {
val event = when (type) {
CardDataType.Number -> TangemPayAnalyticsEvents.CopyCardNumberClicked()
@ -201,8 +208,21 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
analytics.send(event)
clipboardManager.setText(text = text.filterNot { it.isWhitespace() }, isSensitive = true)
}
}
internal enum class CardDataType {
Number, Expiry, CVV
/** Per-block configuration that does not depend on live card data. */
data class Config(
val isEditingNameEnabled: Boolean,
val shouldShowCardDetailsButtonOnCard: Boolean,
)
@AssistedFactory
interface Factory {
fun create(
scope: CoroutineScope,
initialCard: TangemPayCard,
userWalletId: UserWalletId,
config: Config,
onEditNameClick: () -> Unit,
): TangemPayCardDetailsController
}
}

View file

@ -3,15 +3,27 @@ package com.tangem.features.tangempay.model.listener
import kotlinx.coroutines.flow.Flow
/**
* Card details must be hidden after some UI actions from the external component
* Coordinates reveal/hide of card details across card-detail blocks that may be shown simultaneously
* (e.g. several cards in a pager, or the same card on the card page and the edit-name screen).
*
* Events are card-scoped so that one block does not flip the others; [CardDetailsEvent.HideAll] is
* the only broadcast event.
*/
internal interface CardDetailsEventListener {
val event: Flow<CardDetailsEvent>
suspend fun send(event: CardDetailsEvent)
fun send(event: CardDetailsEvent)
}
internal enum class CardDetailsEvent {
Hide, Show
internal sealed interface CardDetailsEvent {
/** Reveal the details of [cardId]. Other blocks treat it as a hint to hide themselves. */
data class Show(val cardId: String) : CardDetailsEvent
/** Hide the details of [cardId]. */
data class Hide(val cardId: String) : CardDetailsEvent
/** Hide details of every block (e.g. leaving the card screen). */
data object HideAll : CardDetailsEvent
}

View file

@ -16,7 +16,9 @@ internal class DefaultCardDetailsEventListener @Inject constructor() : CardDetai
)
override val event: Flow<CardDetailsEvent> = _event
override suspend fun send(event: CardDetailsEvent) {
_event.emit(event)
// tryEmit never fails with replay=1 + DROP_OLDEST, so send can stay non-suspending and be called
// from non-coroutine contexts (e.g. Model.onDestroy).
override fun send(event: CardDetailsEvent) {
_event.tryEmit(event)
}
}

View file

@ -9,7 +9,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route {
data object AccountDetails : TangemPayAccountDetailsInnerRoute()
@Serializable
data object CardDetails : TangemPayAccountDetailsInnerRoute()
data class CardDetails(val cardId: String) : TangemPayAccountDetailsInnerRoute()
@Serializable
data object AddToWallet : TangemPayAccountDetailsInnerRoute()

View file

@ -19,7 +19,7 @@ internal sealed class TangemPayCardDetailsInnerRoute : Route {
data object AddToWallet : TangemPayCardDetailsInnerRoute()
@Serializable
data object EditCardDisplayName : TangemPayCardDetailsInnerRoute()
data class EditCardDisplayName(val cardId: String) : TangemPayCardDetailsInnerRoute()
@Serializable
data object LimitSetup : TangemPayCardDetailsInnerRoute()

View file

@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -17,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButton
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
@ -29,8 +27,6 @@ import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.pay.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.TangemPayAddToWalletStepItemUM
import com.tangem.features.tangempay.entity.TangemPayAddToWalletUM
@ -40,12 +36,11 @@ import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun TangemPayAddToWalletScreen(
state: TangemPayAddToWalletUM,
cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent,
cardDetailsState: TangemPayCardDetailsUM,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
@ -66,9 +61,9 @@ internal fun TangemPayAddToWalletScreen(
contentPadding = PaddingValues(bottom = TangemTheme.dimens.spacing16 + bottomBarHeight),
) {
item(TangemPayCardDetailsUM::class.java) {
cardDetailsBlockComponent.CardDetailsBlockContent(
modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp),
TangemPayCard(
state = cardDetailsState,
modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp),
)
}
@ -181,18 +176,16 @@ private fun PreviewTangemPayAddToWalletScreen() {
onBackClick = {},
onClickOpenWallet = {},
),
cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent(
TangemPayCardDetailsUM(
number = "",
numberShort = "*1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = null,
),
cardDetailsState = TangemPayCardDetailsUM(
number = "",
numberShort = "*1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = null,
),
)
}

View file

@ -9,12 +9,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
@ -27,8 +25,6 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cross_20
import com.tangem.domain.models.pay.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.TangemPayAddToWalletStepItemUM
import com.tangem.features.tangempay.entity.TangemPayAddToWalletUM
@ -39,11 +35,10 @@ import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun TangemPayAddToWalletScreenV2(
state: TangemPayAddToWalletUM,
cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent,
cardDetailsState: TangemPayCardDetailsUM,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
@ -60,11 +55,11 @@ internal fun TangemPayAddToWalletScreenV2(
.verticalScroll(scrollState)
.padding(top = 8.dp, bottom = 12.dp),
) {
cardDetailsBlockComponent.CardDetailsBlockContent(
TangemPayCard(
state = cardDetailsState,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
state = cardDetailsState,
)
DynamicSpacer(scrollState = scrollState)
AddToWalletTitle()
@ -215,18 +210,16 @@ private fun PreviewTangemPayAddToWalletScreen() {
onBackClick = {},
onClickOpenWallet = {},
),
cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent(
TangemPayCardDetailsUM(
number = "",
numberShort = "*1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = null,
),
cardDetailsState = TangemPayCardDetailsUM(
number = "",
numberShort = "*1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text),
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = null,
),
)
}

View file

@ -57,9 +57,9 @@ import com.tangem.core.ui.res.*
import com.tangem.core.ui.test.TangemPayTestTags
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.CardDataType
import com.tangem.features.tangempay.entity.DisplayNameState
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import com.tangem.features.tangempay.model.CardDataType
private const val TEXT_WIDTH_PADDING = 2
private const val FREEZE_ANIMATION_DURATION_MS = 600

View file

@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
@ -24,7 +26,9 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.ds.TangemPagerIndicator
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
@ -34,12 +38,12 @@ import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.*
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
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.*
import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController
import com.tangem.features.tangempay.ui.components.PayContextMenuBlock
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import com.tangem.core.ui.R as CoreUiR
private const val CONTENT_FADE_DURATION_MS = 300
@ -47,9 +51,29 @@ private const val CONTENT_FADE_DURATION_MS = 300
@Composable
internal fun TangemPayCardPageScreen(
state: TangemPayCardPageUM,
cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent,
cardDetailsState: TangemPayCardDetailsUM,
cardControllers: ImmutableList<TangemPayCardDetailsController>,
selectedCardId: String,
onCardSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
TangemPayCardPageScreen(
state = state,
cardSection = {
TangemPayCardSwipePager(
controllers = cardControllers,
selectedCardId = selectedCardId,
onCardSelect = onCardSelect,
)
},
modifier = modifier,
)
}
@Composable
private fun TangemPayCardPageScreen(
state: TangemPayCardPageUM,
modifier: Modifier = Modifier,
cardSection: @Composable () -> Unit,
) {
val isRedesignEnabled = LocalVisaRedesignEnabled.current
Scaffold(
@ -80,10 +104,9 @@ internal fun TangemPayCardPageScreen(
verticalArrangement = Arrangement.spacedBy(if (isRedesignEnabled) 0.dp else TangemTheme.dimens.spacing16),
) {
item(key = "Card") {
cardDetailsBlockComponent.CardDetailsBlockContent(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
state = cardDetailsState,
)
Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) {
cardSection()
}
}
if (isRedesignEnabled && state.settingsV2.isNotEmpty() && state.cardState == TangemPayCardState.Active) {
cardPageItem("Settings buttons") {
@ -98,6 +121,63 @@ internal fun TangemPayCardPageScreen(
}
}
/**
* Renders the card visual(s). With several cards they are wrapped in a [HorizontalPager] so the user
* can swipe to change the management context; the neighbouring cards peek at the screen edges and a
* dots indicator below shows the position. Each page collects its own controller's state so only the
* changed page recomposes. On settle the screen reports the new page via [onCardSelect].
*/
@Composable
private fun TangemPayCardSwipePager(
controllers: ImmutableList<TangemPayCardDetailsController>,
selectedCardId: String,
onCardSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
when {
controllers.isEmpty() -> Unit
controllers.size == 1 -> CardDetailsPage(controller = controllers.first(), modifier = modifier)
else -> {
val initialPage = controllers.indexOfFirst { it.cardId == selectedCardId }.coerceAtLeast(0)
val pagerState = rememberPagerState(initialPage = initialPage) { controllers.size }
LaunchedEffect(pagerState, controllers) {
snapshotFlow { pagerState.settledPage }
.distinctUntilChanged()
.collect(onCardSelect)
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
// Side padding keeps the current card centered while the neighbours peek at the edges.
contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing32),
pageSpacing = TangemTheme.dimens.spacing8,
beyondViewportPageCount = 1,
key = { controllers[it].cardId },
) { page ->
CardDetailsPage(controller = controllers[page])
}
TangemPagerIndicator(
pagerState = pagerState,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing12),
)
}
}
}
}
@Composable
private fun CardDetailsPage(controller: TangemPayCardDetailsController, modifier: Modifier = Modifier) {
val cardDetailsState by controller.uiState.collectAsStateWithLifecycle()
TangemPayCard(state = cardDetailsState, modifier = modifier)
}
private fun LazyListScope.cardState(state: TangemPayCardPageUM) {
when (state.cardState) {
TangemPayCardState.Active -> {
@ -254,40 +334,26 @@ private fun TangemPayCardPageScreenPreviewV1() {
TangemThemePreview {
TangemPayCardPageScreen(
state = TangemPayCardPageUM.stub(),
cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent(
TangemPayCardDetailsUM(
number = "•••• •••• •••• 1245",
numberShort = "··1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = true,
),
),
),
cardDetailsState = TangemPayCardDetailsUM(
number = "•••• •••• •••• 1245",
numberShort = "··1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = false,
),
),
cardSection = { TangemPayCard(state = previewCardDetailsState()) },
)
}
}
private fun previewCardDetailsState(): TangemPayCardDetailsUM = TangemPayCardDetailsUM(
number = "•••• •••• •••• 1245",
numberShort = "··1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = false,
),
)
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -299,36 +365,7 @@ private fun TangemPayCardPageScreenPreviewV2() {
) {
TangemPayCardPageScreen(
state = TangemPayCardPageUM.stub(),
cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent(
TangemPayCardDetailsUM(
number = "•••• •••• •••• 1245",
numberShort = "··1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = true,
),
),
),
cardDetailsState = TangemPayCardDetailsUM(
number = "•••• •••• •••• 1245",
numberShort = "··1245",
expiry = "••/••",
cvv = "•••",
onCopy = { _, _ -> },
onClick = {},
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
displayNameState = DisplayNameState.Display(
displayName = "Tangem Pay Card",
onClick = {},
isEditingEnabled = false,
),
),
cardSection = { TangemPayCard(state = previewCardDetailsState()) },
)
}
}

View file

@ -19,7 +19,6 @@ import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cross_20
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.TangemPayEditDisplayNameUM
@ -28,21 +27,18 @@ import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM
internal fun TangemPayEditDisplayNameScreen(
isRedesignEnabled: Boolean,
state: TangemPayEditDisplayNameUM,
cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent,
cardDetailsState: TangemPayCardDetailsUM,
modifier: Modifier = Modifier,
) {
if (isRedesignEnabled) {
TangemPayEditDisplayNameScreenV2(
state = state,
cardDetailsBlockComponent = cardDetailsBlockComponent,
cardDetailsState = cardDetailsState,
modifier = modifier,
)
} else {
TangemPayEditDisplayNameScreenV1(
state = state,
cardDetailsBlockComponent = cardDetailsBlockComponent,
cardDetailsState = cardDetailsState,
modifier = modifier,
)
@ -52,7 +48,6 @@ internal fun TangemPayEditDisplayNameScreen(
@Composable
internal fun TangemPayEditDisplayNameScreenV1(
state: TangemPayEditDisplayNameUM,
cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent,
cardDetailsState: TangemPayCardDetailsUM,
modifier: Modifier = Modifier,
) {
@ -80,11 +75,11 @@ internal fun TangemPayEditDisplayNameScreenV1(
}
}
cardDetailsBlockComponent.CardDetailsBlockContent(
TangemPayCard(
state = cardDetailsState,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp),
state = cardDetailsState,
)
Spacer(modifier = Modifier.weight(1f))
@ -105,7 +100,6 @@ internal fun TangemPayEditDisplayNameScreenV1(
@Composable
internal fun TangemPayEditDisplayNameScreenV2(
state: TangemPayEditDisplayNameUM,
cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent,
cardDetailsState: TangemPayCardDetailsUM,
modifier: Modifier = Modifier,
) {
@ -133,9 +127,9 @@ internal fun TangemPayEditDisplayNameScreenV2(
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
cardDetailsBlockComponent.CardDetailsBlockContent(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2),
TangemPayCard(
state = cardDetailsState,
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2),
)
}

View file

@ -0,0 +1,211 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SecondaryButtonIconEnd
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
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
import com.tangem.features.tangempay.entity.TangemPayIssueAdditionalCardUM
@Composable
internal fun TangemPayIssueAdditionalCardContent(state: TangemPayIssueAdditionalCardUM) {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = state.onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
onBack = state.onDismiss,
containerColor = TangemTheme.colors.background.secondary,
title = {
TangemModalBottomSheetTitle(
endIconRes = R.drawable.ic_close_24,
onEndClick = state.onDismiss,
)
},
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CardIcon()
Text(
modifier = Modifier.padding(top = 16.dp),
text = stringResourceSafe(R.string.tangempay_issue_additional_card_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = stringResourceSafe(R.string.tangempay_issue_additional_card_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
FeeRow(modifier = Modifier.padding(top = 16.dp), state = state)
if (state.isBalanceInsufficient) {
InsufficientFundsBlock(modifier = Modifier.padding(top = 8.dp), onAddFundsClick = state.onAddFundsClick)
}
PrimaryButton(
modifier = Modifier
.padding(vertical = 16.dp)
.fillMaxWidth(),
text = stringResourceSafe(R.string.tangempay_issue_card),
showProgress = state.isLoading,
enabled = !state.isLoading && !state.isBalanceInsufficient,
onClick = state.onIssueClick,
)
}
}
}
@Composable
private fun CardIcon() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
shape = CircleShape,
)
.size(48.dp),
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = ImageVector.vectorResource(id = R.drawable.ic_credit_card_add_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
@Composable
private fun FeeRow(state: TangemPayIssueAdditionalCardUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.action)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = stringResourceSafe(R.string.tangempay_issue_additional_card_fee_label),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = state.feeText,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
private fun InsufficientFundsBlock(onAddFundsClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.background.action)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Image(
modifier = Modifier.size(20.dp),
painter = painterResource(id = R.drawable.img_usdc_16),
contentDescription = null,
)
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(
text = stringResourceSafe(R.string.tangempay_reissue_card_insufficient_funds_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = stringResourceSafe(
R.string.tangempay_reissue_card_insufficient_funds_subtitle,
),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
SecondaryButtonIconEnd(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
text = stringResourceSafe(R.string.common_add_funds),
iconResId = R.drawable.ic_plus_24,
onClick = onAddFundsClick,
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayIssueAdditionalCardContentPreview(
@PreviewParameter(IssueAdditionalCardPreviewProvider::class) state: TangemPayIssueAdditionalCardUM,
) {
TangemThemePreview {
TangemPayIssueAdditionalCardContent(state = state)
}
}
private class IssueAdditionalCardPreviewProvider : PreviewParameterProvider<TangemPayIssueAdditionalCardUM> {
override val values: Sequence<TangemPayIssueAdditionalCardUM>
get() = sequenceOf(
TangemPayIssueAdditionalCardUM(
isBalanceInsufficient = false,
feeText = "10 $",
isLoading = false,
onIssueClick = {},
onAddFundsClick = {},
onDismiss = {},
),
TangemPayIssueAdditionalCardUM(
isBalanceInsufficient = true,
feeText = "10 $",
isLoading = false,
onIssueClick = {},
onAddFundsClick = {},
onDismiss = {},
),
TangemPayIssueAdditionalCardUM(
isBalanceInsufficient = false,
feeText = "10 $",
isLoading = true,
onIssueClick = {},
onAddFundsClick = {},
onDismiss = {},
),
)
}

View file

@ -9,7 +9,7 @@ internal interface TangemPayDetailIntents {
fun onClickAddFunds()
fun onClickWithdraw()
fun onClickTermsAndLimits()
fun onCardClick()
fun onCardClick(cardId: String)
fun onAddCardClick()
fun onRemoveAccount()
}

View file

@ -4,7 +4,9 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.message.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.BottomSheetMessage
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.bottomSheetMessage
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_snowflake_20
@ -147,6 +149,32 @@ internal object TangemPayMessagesFactory {
}
}
fun createMaximumCardsIssued(maxCards: Int): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {
icon(R.drawable.img_attention_20) {
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Attention
}
title = resourceReference(R.string.tangempay_maximum_cards_issued_title)
body = resourceReference(
id = R.string.tangempay_maximum_cards_issued_description,
formatArgs = wrappedList(maxCards),
)
}
primaryButton {
text = resourceReference(R.string.common_got_it)
onClick { closeBs() }
}
}
}
fun createGenericError(): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),
message = resourceReference(R.string.common_try_again_later),
)
}
fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage {
return bottomSheetMessage {
infoBlock {

View file

@ -0,0 +1,257 @@
package com.tangem.features.tangempay.model.controller
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.error.UniversalError
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.features.tangempay.entity.CardDataType
import com.tangem.features.tangempay.model.listener.CardDetailsEvent
import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener
import com.tangem.utils.StringsSigns
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
private const val SHOW_DETAILS_TIME = 30_000L
internal class TangemPayCardDetailsControllerTest {
private val userWalletId = UserWalletId("123")
private val cardId = "card_1"
private val otherCardId = "card_2"
private val repository: TangemPayCardDetailsRepository = mockk(relaxed = true)
private val clipboardManager: ClipboardManager = mockk(relaxed = true)
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
private val supplier: PaymentAccountStatusSupplier = mockk()
private val supplierFlow = MutableSharedFlow<AccountStatus.Payment>(replay = 1)
private val eventListener = DefaultCardDetailsEventListener()
private val details = TangemPayCardDetails(
pan = "4242424242424242",
cvv = "123",
expirationYear = "2030",
expirationMonth = "9",
)
init {
every { supplier.invoke(userWalletId) } returns supplierFlow
every { repository.cardFrozenState(any()) } returns emptyFlow()
}
@Test
fun `GIVEN card WHEN created THEN initial state is hidden with masked digits`() = runTest {
val controller = createController(scope = backgroundScope, card = card(lastDigits = "9999"))
runCurrent()
val state = controller.uiState.value
assertThat(state.isHidden).isTrue()
assertThat(state.numberShort).isEqualTo("${StringsSigns.ASTERISK}9999")
}
@Test
fun `GIVEN Show for this card WHEN event received THEN details revealed and analytics sent`() = runTest {
coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right()
val controller = createController(scope = backgroundScope)
runCurrent()
eventListener.send(CardDetailsEvent.Show(cardId))
runCurrent()
verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.ViewCardDetailsClicked()) }
coVerify(exactly = 1) { repository.revealCardDetails(userWalletId, cardId) }
val state = controller.uiState.value
assertThat(state.isHidden).isFalse()
assertThat(state.cvv).isEqualTo("123")
assertThat(state.expiry).isEqualTo("09/30")
}
@Test
fun `GIVEN reveal fails WHEN event received THEN snackbar shown and state hidden`() = runTest {
coEvery { repository.revealCardDetails(userWalletId, cardId) } returns ERROR.left()
val controller = createController(scope = backgroundScope)
runCurrent()
eventListener.send(CardDetailsEvent.Show(cardId))
runCurrent()
verify(exactly = 1) { uiMessageSender.send(any<SnackbarMessage>()) }
assertThat(controller.uiState.value.isHidden).isTrue()
}
@Test
fun `GIVEN revealed WHEN Show for another card THEN this card hides`() = runTest {
coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right()
val controller = createController(scope = backgroundScope)
runCurrent()
eventListener.send(CardDetailsEvent.Show(cardId))
runCurrent()
eventListener.send(CardDetailsEvent.Show(otherCardId))
runCurrent()
assertThat(controller.uiState.value.isHidden).isTrue()
}
@Test
fun `GIVEN revealed WHEN Hide for another card THEN this card stays revealed`() = runTest {
coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right()
val controller = createController(scope = backgroundScope)
runCurrent()
eventListener.send(CardDetailsEvent.Show(cardId))
runCurrent()
eventListener.send(CardDetailsEvent.Hide(otherCardId))
runCurrent()
assertThat(controller.uiState.value.isHidden).isFalse()
}
@Test
fun `GIVEN revealed WHEN HideAll THEN this card hides`() = runTest {
coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right()
val controller = createController(scope = backgroundScope)
runCurrent()
eventListener.send(CardDetailsEvent.Show(cardId))
runCurrent()
eventListener.send(CardDetailsEvent.HideAll)
runCurrent()
assertThat(controller.uiState.value.isHidden).isTrue()
}
@Test
fun `GIVEN revealed WHEN show timer elapses THEN details auto-hide`() = runTest {
coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right()
val controller = createController(scope = backgroundScope)
runCurrent()
eventListener.send(CardDetailsEvent.Show(cardId))
runCurrent()
advanceTimeBy(SHOW_DETAILS_TIME + 1)
runCurrent()
assertThat(controller.uiState.value.isHidden).isTrue()
}
@Test
fun `GIVEN supplier emits actual loaded WHEN card present THEN frozen state and digits updated`() = runTest {
val updatedCard = card(lastDigits = "5678", frozenState = TangemPayCardFrozenState.Frozen)
val loaded = mockk<PaymentAccountStatusValue.Loaded> {
every { source } returns StatusSource.ACTUAL
every { cards } returns listOf(updatedCard)
}
val payment = mockk<AccountStatus.Payment> {
every { value } returns loaded
}
val controller = createController(scope = backgroundScope)
runCurrent()
supplierFlow.emit(payment)
runCurrent()
val state = controller.uiState.value
assertThat(state.numberShort).isEqualTo("${StringsSigns.ASTERISK}5678")
assertThat(state.cardFrozenState).isEqualTo(TangemPayCardFrozenState.Frozen)
assertThat(state.isActionsAvailable).isTrue()
}
@Test
fun `GIVEN copy cvv WHEN onCopy invoked THEN clipboard set as sensitive and analytics sent`() = runTest {
val controller = createController(scope = backgroundScope)
runCurrent()
controller.uiState.value.onCopy("1 2 3", CardDataType.CVV)
verify(exactly = 1) { clipboardManager.setText(text = "123", isSensitive = true) }
verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.CopyCardCVVClicked()) }
}
@Test
fun `GIVEN disposed WHEN Show received THEN reveal not triggered`() = runTest {
coEvery { repository.revealCardDetails(userWalletId, cardId) } returns details.right()
val controller = createController(scope = backgroundScope)
runCurrent()
controller.dispose()
eventListener.send(CardDetailsEvent.Show(cardId))
advanceUntilIdle()
coVerify(exactly = 0) { repository.revealCardDetails(any(), any()) }
}
private fun TestScope.createController(
scope: CoroutineScope,
card: TangemPayCard = card(),
config: TangemPayCardDetailsController.Config = TangemPayCardDetailsController.Config(
isEditingNameEnabled = true,
shouldShowCardDetailsButtonOnCard = false,
),
onEditNameClick: () -> Unit = {},
): TangemPayCardDetailsController = TangemPayCardDetailsController(
scope = scope,
initialCard = card,
userWalletId = userWalletId,
config = config,
onEditNameClick = onEditNameClick,
cardDetailsRepository = repository,
clipboardManager = clipboardManager,
uiMessageSender = uiMessageSender,
cardDetailsEventListener = eventListener,
analytics = analytics,
paymentAccountStatusSupplier = supplier,
)
private fun card(
id: String = cardId,
lastDigits: String = "1234",
displayName: CardDisplayName? = null,
frozenState: TangemPayCardFrozenState = TangemPayCardFrozenState.Unfrozen,
state: TangemPayCardState = TangemPayCardState.Active,
): TangemPayCard = TangemPayCard(
id = id,
productInstanceId = "product_$id",
cardStatus = TangemPayCard.Status.ACTIVE,
hasPinCode = true,
displayName = displayName,
limit = null,
frozenState = frozenState,
lastDigits = lastDigits,
state = state,
)
private companion object {
val ERROR = object : UniversalError {
override val errorCode: Int = 0
}
}
}

View file

@ -1484,13 +1484,15 @@ internal class TokenDetailsModel @Inject constructor(
flow2 = availabilityFlow,
flow3 = entryInfoFlow,
flow4 = selectedAppCurrencyFlow,
) { status, availability, entryInfo, appCurrency ->
flow5 = redesignStateController.isBalanceHidden,
) { status, availability, entryInfo, appCurrency, isBalanceHidden ->
redesignStateController.update(
UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = status,
stakingAvailability = availability,
stakingEntryInfo = entryInfo,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
clickIntents = this,
),
)

View file

@ -13,8 +13,11 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ -26,6 +29,10 @@ internal class TokenDetailsStateController @Inject constructor() {
val value: TokenDetailsUM get() = uiState.value
val isBalanceHidden: Flow<Boolean> = uiState
.map { it.isBalanceHidden }
.distinctUntilChanged()
fun update(function: (TokenDetailsUM) -> TokenDetailsUM) {
uiState.update(function = function)
}

View file

@ -5,6 +5,7 @@ import com.tangem.common.getRewardStakingBalance
import com.tangem.common.getTotalStakingBalance
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
@ -34,11 +35,12 @@ internal class UpdateStakingNotificationTransformer(
private val stakingAvailability: StakingAvailability,
private val stakingEntryInfo: StakingEntryInfo?,
private val appCurrency: AppCurrency,
private val isBalanceHidden: Boolean,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
return prevState.copy(earnBlockState = buildEarnBlock(prevState.isBalanceHidden))
return prevState.copy(earnBlockState = buildEarnBlock(isBalanceHidden))
}
private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? {
@ -56,12 +58,12 @@ internal class UpdateStakingNotificationTransformer(
iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_staking),
style = EarnBlockUM.TitleUM.Style.Small,
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Disabled,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.staking_notification_network_error_text),
style = EarnBlockUM.SubtitleUM.Style.Large,
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = null,
@ -117,12 +119,12 @@ internal class UpdateStakingNotificationTransformer(
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(id = CoreResR.string.common_staking),
style = EarnBlockUM.TitleUM.Style.Small,
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stakeAvailableSubtitle(availability.option.displayRewardInfo),
style = EarnBlockUM.SubtitleUM.Style.Large,
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
@ -165,10 +167,10 @@ internal class UpdateStakingNotificationTransformer(
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_enabled),
style = EarnBlockUM.TitleUM.Style.Small,
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = getRewardSubtitle(status, rewardFiatAmount),
subtitleUM = getRewardSubtitle(status, rewardFiatAmount, isBalanceHidden),
trailingUM = EarnBlockUM.TrailingUM.Balance(
fiatValue = fiatAmount.formatStyled {
fiat(
@ -196,6 +198,7 @@ internal class UpdateStakingNotificationTransformer(
private fun getRewardSubtitle(
status: CryptoCurrencyStatus,
stakingRewardAmount: BigDecimal?,
isBalanceHidden: Boolean,
): EarnBlockUM.SubtitleUM? {
val blockchainId = status.currency.network.rawId
val isCoin = status.currency.id.isCoin
@ -224,16 +227,20 @@ internal class UpdateStakingNotificationTransformer(
-> return null
RewardBlockType.EthereumEarnedRewards -> {
val cryptoRewardAmount = (stakingBalance as? StakingBalance.Data.P2PEthPool)?.totalRewards
resourceReference(
R.string.staking_details_autocompound_rewards_earned,
wrappedList(
cryptoRewardAmount.format {
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
},
return EarnBlockUM.SubtitleUM.AccentedText(
text = resourceReference(R.string.staking_details_autocompound_rewards_compounded),
accent = resourceReference(
R.string.staking_details_autocompound_funds_earned,
wrappedList(
cryptoRewardAmount.format {
crypto(
symbol = status.currency.symbol,
decimals = status.currency.decimals,
)
}.orMaskWithStars(isBalanceHidden),
),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
)
}
RewardBlockType.RewardsRequirementsError,
@ -241,18 +248,18 @@ internal class UpdateStakingNotificationTransformer(
-> resourceReference(
R.string.staking_details_rewards_to_claim,
wrappedList(
stakingRewardAmount.format { fiat(appCurrency.code, appCurrency.symbol) },
stakingRewardAmount.format { fiat(appCurrency.code, appCurrency.symbol) }
.orMaskWithStars(isBalanceHidden),
),
)
}
val isAccent = rewardBlockType == RewardBlockType.Rewards ||
rewardBlockType == RewardBlockType.RewardsRequirementsError ||
rewardBlockType == RewardBlockType.EthereumEarnedRewards
rewardBlockType == RewardBlockType.RewardsRequirementsError
return EarnBlockUM.SubtitleUM.Text(
text = text,
style = EarnBlockUM.SubtitleUM.Style.Large,
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = if (isAccent) EarnBlockUM.SubtitleUM.Tone.Accent else EarnBlockUM.SubtitleUM.Tone.Disabled,
)
}

View file

@ -4,15 +4,23 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.BalanceItem
import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.staking.YieldBalanceItem
import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingOption
import com.tangem.utils.StringsSigns.THREE_STARS
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
@ -84,29 +92,114 @@ class UpdateStakingNotificationTransformerTest {
assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java)
}
@Test
fun `GIVEN active staked balance AND balance hidden WHEN transform THEN trailing balance hidden`() {
// Arrange
val status = buildStatus(
networkRawId = "ethereum",
symbol = "ETH",
isCoin = false,
stakingBalance = stakeKitBalance(staked = BigDecimal("100"), rewards = BigDecimal("5")),
)
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
isBalanceHidden = true,
)
// Act
val result = transformer.transform(initialState())
// Assert
val content = result.earnBlockState as EarnBlockUM.Content
val trailing = content.trailingUM as EarnBlockUM.TrailingUM.Balance
assertThat(trailing.isBalanceHidden).isTrue()
}
@Test
fun `GIVEN rewards to claim AND balance hidden WHEN transform THEN reward amount masked with stars`() {
// Arrange
val status = buildStatus(
networkRawId = "ethereum",
symbol = "ETH",
isCoin = false,
stakingBalance = stakeKitBalance(staked = BigDecimal("100"), rewards = BigDecimal("5")),
)
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
isBalanceHidden = true,
)
// Act
val result = transformer.transform(initialState())
// Assert
val content = result.earnBlockState as EarnBlockUM.Content
val subtitle = content.subtitleUM as EarnBlockUM.SubtitleUM.Text
assertThat(rewardFormatArg(subtitle.text)).isEqualTo(THREE_STARS)
}
@Test
fun `GIVEN rewards to claim AND balance visible WHEN transform THEN reward amount not masked`() {
// Arrange
val status = buildStatus(
networkRawId = "ethereum",
symbol = "ETH",
isCoin = false,
stakingBalance = stakeKitBalance(staked = BigDecimal("100"), rewards = BigDecimal("5")),
)
val transformer = createTransformer(
availability = availableOption(BigDecimal("4.2")),
entryInfo = StakingEntryInfo(tokenSymbol = "ETH"),
cryptoCurrencyStatus = status,
isBalanceHidden = false,
)
// Act
val result = transformer.transform(initialState())
// Assert
val content = result.earnBlockState as EarnBlockUM.Content
val subtitle = content.subtitleUM as EarnBlockUM.SubtitleUM.Text
assertThat(rewardFormatArg(subtitle.text)).isNotEqualTo(THREE_STARS)
}
private fun rewardFormatArg(text: TextReference): Any? =
(text as TextReference.Res).formatArgs.data.firstOrNull()
private fun createTransformer(
availability: StakingAvailability,
entryInfo: StakingEntryInfo?,
cryptoCurrencyStatus: CryptoCurrencyStatus = buildStatus(),
isBalanceHidden: Boolean = false,
) = UpdateStakingNotificationTransformer(
cryptoCurrencyStatus = buildStatus(),
cryptoCurrencyStatus = cryptoCurrencyStatus,
stakingAvailability = availability,
stakingEntryInfo = entryInfo,
appCurrency = AppCurrency.Default,
isBalanceHidden = isBalanceHidden,
clickIntents = clickIntents,
)
private fun buildStatus(): CryptoCurrencyStatus {
private fun buildStatus(
networkRawId: String = "solana",
symbol: String = "SOL",
isCoin: Boolean = true,
stakingBalance: StakingBalance = mockk(relaxed = true),
): CryptoCurrencyStatus {
val network = mockk<Network>(relaxed = true) {
every { rawId } returns "solana"
every { rawId } returns networkRawId
every { isTestnet } returns false
}
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { symbol } returns "SOL"
every { this@mockk.symbol } returns symbol
every { decimals } returns 9
every { this@mockk.network } returns network
every { id.isCoin } returns true
every { id.isCoin } returns isCoin
}
val stakingBalance = mockk<StakingBalance>(relaxed = true)
val value = mockk<CryptoCurrencyStatus.Value>(relaxed = true) {
every { this@mockk.stakingBalance } returns stakingBalance
every { fiatRate } returns BigDecimal.ONE
@ -115,6 +208,35 @@ class UpdateStakingNotificationTransformerTest {
return CryptoCurrencyStatus(currency = currency, value = value)
}
/** Builds an active StakeKit balance with the given [staked] and [rewards] amounts. */
private fun stakeKitBalance(staked: BigDecimal, rewards: BigDecimal): StakingBalance.Data.StakeKit {
val stakingId = StakingID(integrationId = "ethereum-eth-native-staking", address = "0xabc")
return StakingBalance.Data.StakeKit(
stakingId = stakingId,
source = StatusSource.ACTUAL,
balance = YieldBalanceItem(
integrationId = stakingId.integrationId,
items = listOf(
balanceItem(type = BalanceType.STAKED, amount = staked),
balanceItem(type = BalanceType.REWARDS, amount = rewards),
),
),
)
}
private fun balanceItem(type: BalanceType, amount: BigDecimal): BalanceItem = BalanceItem(
groupId = "group",
token = YieldToken.ETH,
type = type,
amount = amount,
rawCurrencyId = null,
validatorAddress = null,
date = null,
pendingActions = emptyList(),
pendingActionsConstraints = emptyList(),
isPending = false,
)
private fun availableOption(apy: BigDecimal): StakingAvailability.Available {
val option = mockk<StakingOption>(relaxed = true) {
every { this@mockk.apy } returns apy
@ -122,7 +244,7 @@ class UpdateStakingNotificationTransformerTest {
return StakingAvailability.Available(option = option)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
private fun initialState(isBalanceHidden: Boolean = false): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Solana"),
subtitle = stringReference("Solana network"),
@ -134,7 +256,7 @@ class UpdateStakingNotificationTransformerTest {
earnBlockState = null,
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isBalanceHidden = isBalanceHidden,
isMarketPriceAvailable = false,
addFundsUM = AddFundsUM.Loading,
transferUM = TransferUM.Loading,