Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-30 15:42:03 +07:00
commit cae7844408
49 changed files with 537 additions and 273 deletions

@ -1 +1 @@
Subproject commit 098662beb5b0123b11f5ee4873d4bd667ac93c73
Subproject commit 8285f8d2896d2994337f55a7b3a54fd5acc24304

View file

@ -204,6 +204,16 @@ internal object YieldSupplyDomainModule {
)
}
@Provides
@Singleton
fun provideYieldSupplyEnterStatusFlowUseCase(
yieldSupplyRepository: YieldSupplyRepository,
): YieldSupplyEnterStatusFlowUseCase {
return YieldSupplyEnterStatusFlowUseCase(
yieldSupplyRepository = yieldSupplyRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetShouldShowMainPromoUseCase(

View file

@ -95,8 +95,6 @@ private fun TrendingArticle(
TangemTheme.colors.text.primary1
},
style = TangemTheme.typography.h3,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)

View file

@ -47,10 +47,6 @@
"name": "TANGEM_PAY_ENABLED",
"version": "5.31.0"
},
{
"name": "TANGEM_PAY_ENTRYPOINT_ENABLED",
"version": "undefined"
},
{
"name": "NEW_TOKEN_RECEIVE_ENABLED",
"version": "5.28.0"

View file

@ -1469,6 +1469,9 @@
<string name="tangem_pay_freeze_card_freeze">Freeze</string>
<string name="tangem_pay_freeze_card_success">Your card is frozen.</string>
<string name="tangem_pay_get_help">Get Help</string>
<string name="tangem_pay_history_item_spend_mc_declined_reason">Reason: %s</string>
<string name="tangem_pay_history_item_spend_mc_title_format" formatted="false">%s · %s</string>
<string name="tangem_pay_history_item_spend_mcc">MCC %s</string>
<string name="tangem_pay_other">Other</string>
<string name="tangem_pay_rooted_device_subtitle">Unable to use on rooted devices</string>
<string name="tangem_pay_status_completed">Completed</string>

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ButtonColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
@ -54,12 +55,16 @@ fun TextButtonIconStart(
onClick: () -> Unit,
modifier: Modifier = Modifier,
colors: ButtonColors = TangemButtonsDefaults.defaultTextButtonColors,
tint: Color? = null,
enabled: Boolean = true,
) {
TangemButton(
modifier = modifier,
text = text,
icon = TangemButtonIconPosition.Start(iconResId),
icon = TangemButtonIconPosition.Start(
iconResId = iconResId,
iconTint = tint,
),
onClick = onClick,
enabled = enabled,
showProgress = false,
@ -117,13 +122,21 @@ fun PrimaryButtonIconEnd(
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: TangemButtonSize = TangemButtonSize.Default,
tint: Color? = null,
showProgress: Boolean = false,
enabled: Boolean = true,
) {
TangemButton(
modifier = modifier,
text = text,
icon = if (iconResId != null) TangemButtonIconPosition.End(iconResId) else TangemButtonIconPosition.None,
icon = if (iconResId != null) {
TangemButtonIconPosition.End(
iconResId = iconResId,
iconTint = tint,
)
} else {
TangemButtonIconPosition.None
},
onClick = onClick,
colors = TangemButtonsDefaults.primaryButtonColors,
enabled = enabled,
@ -145,11 +158,15 @@ fun PrimaryButtonIconEndTwoLines(
showProgress: Boolean = false,
enabled: Boolean = true,
additionalText: String? = null,
tint: Color? = null,
) {
TangemButton(
modifier = modifier,
text = text,
icon = TangemButtonIconPosition.End(iconResId),
icon = TangemButtonIconPosition.End(
iconResId = iconResId,
iconTint = tint,
),
onClick = onClick,
colors = TangemButtonsDefaults.primaryButtonColors,
enabled = enabled,
@ -171,13 +188,17 @@ fun PrimaryButtonIconStart(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
tint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
text = text,
icon = TangemButtonIconPosition.Start(iconResId),
icon = TangemButtonIconPosition.Start(
iconResId = iconResId,
iconTint = tint,
),
onClick = onClick,
colors = TangemButtonsDefaults.primaryButtonColors,
enabled = enabled,
@ -225,13 +246,17 @@ fun SecondaryButtonIconEnd(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
tint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
text = text,
icon = TangemButtonIconPosition.End(iconResId),
icon = TangemButtonIconPosition.End(
iconResId = iconResId,
iconTint = tint,
),
onClick = onClick,
colors = TangemButtonsDefaults.secondaryButtonColors,
enabled = enabled,
@ -253,13 +278,17 @@ fun SecondaryButtonIconStart(
modifier: Modifier = Modifier,
showProgress: Boolean = false,
enabled: Boolean = true,
iconTint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
text = text,
icon = TangemButtonIconPosition.Start(iconResId),
icon = TangemButtonIconPosition.Start(
iconResId = iconResId,
iconTint = iconTint,
),
onClick = onClick,
colors = TangemButtonsDefaults.secondaryButtonColors,
enabled = enabled,

View file

@ -99,7 +99,7 @@ fun TangemButton(
.padding(vertical = 2.dp)
.testTag(BaseButtonTestTags.ICON),
painter = painterResource(id = iconResId),
tint = colors.contentColor(enabled = enabled).value,
tint = icon.iconTint ?: colors.contentColor(enabled = enabled).value,
contentDescription = null,
)
},

View file

@ -2,17 +2,27 @@ package com.tangem.core.ui.components.buttons.common
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
sealed interface TangemButtonIconPosition {
val iconResId: Int?
val iconTint: Color?
data class Start(@DrawableRes override val iconResId: Int) : TangemButtonIconPosition
data class Start(
@DrawableRes override val iconResId: Int,
override val iconTint: Color? = null,
) : TangemButtonIconPosition
data class End(@DrawableRes override val iconResId: Int) : TangemButtonIconPosition
data class End(
@DrawableRes override val iconResId: Int,
override val iconTint: Color? = null,
) : TangemButtonIconPosition
data object None : TangemButtonIconPosition {
@DrawableRes
override val iconResId: Int? = null
override val iconTint: Color? = null
}
}

View file

@ -17,7 +17,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.BuildConfig
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.account.AccountCharIcon
@ -33,9 +32,9 @@ import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.internal.GroupTitleItem
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.ProvideSharedTransitionScope
/**
* Multi-currency content item
@ -69,7 +68,7 @@ fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: M
@Suppress("MagicNumber", "ReusedModifierInstance", "LongMethod")
@Composable
fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
SharedTransitionLayout(modifier) {
ProvideSharedTransitionScope(modifier) {
val iconSharedContentState = rememberSharedContentState(key = "icon_${state.id}")
val titleSharedContentState = rememberSharedContentState(key = "title_${state.id}")
val boundsTransform = BoundsTransform { _, _ -> tween(350) }
@ -101,13 +100,11 @@ fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolea
state = iconState,
withFixedSize = false,
modifier = modifier
.conditional(BuildConfig.DEBUG.not()) {
sharedBounds(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
)
},
.sharedBounds(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
),
)
},
title = { modifier: Modifier ->
@ -126,14 +123,12 @@ fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolea
state = state.tokenItemUM.titleState,
textStyle = textStyle.copy(fontSize = textSize.sp),
modifier = modifier
.conditional(BuildConfig.DEBUG.not()) {
sharedBounds(
sharedContentState = titleSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart),
)
},
.sharedBounds(
sharedContentState = titleSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart),
),
)
},
)

View file

@ -6,7 +6,6 @@ import com.tangem.core.ui.utils.DateTimeFormatters.dateMMMdd
import com.tangem.core.ui.utils.DateTimeFormatters.dateTimeFormatter
import com.tangem.core.ui.utils.DateTimeFormatters.dateYYYY
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatter
import org.joda.time.format.DateTimeFormatterBuilder
@ -18,7 +17,7 @@ object DateTimeFormatters {
/**
* Determine if the time is in 12-hour format ("10:00 PM") for the current locale.
*/
val is12HourFormat by lazy {
private val is12HourFormat by lazy {
/**
* Two SS means, SHORT style for date and time.
* If pattern contains "a", it means time is in 12 hour format.
@ -103,17 +102,15 @@ object DateTimeFormatters {
* Local full date formatter (e.g., "dd MMMM, HH:mm")
*/
val localFullDate: DateTimeFormatter by lazy {
val locale = Locale.getDefault()
val datePattern = DateFormat.getBestDateTimePattern(locale, "dd MMMM")
val timeSkeleton = if (is12HourFormat) "h:mm a" else "HH:mm"
val timePattern = DateFormat.getBestDateTimePattern(locale, timeSkeleton)
val fullPattern = "$datePattern, $timePattern"
DateTimeFormatterBuilder()
.appendDayOfMonth(2)
.appendLiteral(' ')
.appendMonthOfYearText()
.appendLiteral(", ")
.appendHourOfDay(2)
.appendLiteral(':')
.appendMinuteOfHour(2)
.appendPattern(fullPattern)
.toFormatter()
.withLocale(Locale.getDefault())
.withZone(DateTimeZone.getDefault())
.withLocale(locale)
}
fun formatDate(date: DateTime, formatter: DateTimeFormatter = dateFormatter): String {

View file

@ -0,0 +1,36 @@
package com.tangem.core.ui.utils
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
@Composable
fun TangemSharedTransitionLayout(
modifier: Modifier = Modifier,
content: @Composable SharedTransitionScope.() -> Unit,
) {
SharedTransitionLayout(modifier) {
val sharedTransitionScope = this
CompositionLocalProvider(
LocalSharedTransitionScope provides sharedTransitionScope,
) {
content()
}
}
}
@Composable
fun ProvideSharedTransitionScope(modifier: Modifier = Modifier, content: @Composable SharedTransitionScope.() -> Unit) {
val sharedTransitionScope = LocalSharedTransitionScope.current
Box(modifier) {
sharedTransitionScope.content()
}
}
private val LocalSharedTransitionScope = staticCompositionLocalOf<SharedTransitionScope> {
error("No SharedTransitionScope provided")
}

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M13.75,1.75C16.73,1.75 19.084,4.093 19.084,7.083C19.084,8.9 18.257,10.495 16.961,12.081C15.673,13.657 13.844,15.315 11.713,17.247L10.505,18.347L10,18.806L9.495,18.347L8.288,17.247C6.157,15.315 4.327,13.657 3.039,12.081C1.743,10.495 0.917,8.9 0.917,7.083C0.917,4.093 3.27,1.75 6.25,1.75C7.646,1.75 8.986,2.29 10,3.174C11.013,2.29 12.353,1.75 13.75,1.75Z"
android:strokeWidth="1.5"
android:fillColor="#FF3333"
android:strokeColor="#FF3333"/>
</vector>

View file

@ -48,15 +48,17 @@ internal class DefaultMainAccountTokensMigration(
exception
}
val mainAccount = findAccount(response = response, derivationIndex = DerivationIndex.Main)
val notMainAccounts = response.accounts
val customAccounts = response.accounts
.filterNot { accountDTO -> accountDTO.derivationIndex.toDerivationIndex().isMain }
if (notMainAccounts.isEmpty()) {
if (customAccounts.isEmpty()) {
Timber.i("There is only the Main account. Nothing to migrate")
return@either response
}
val customAccountIndexes = customAccounts.mapTo(hashSetOf(), WalletAccountDTO::derivationIndex)
val unassignedTokens = mainAccount.groupUnassignedTokens()
.filter { it.key.value in customAccountIndexes }
if (unassignedTokens.isEmpty()) {
Timber.i("No unassigned tokens found for migration")
@ -64,7 +66,7 @@ internal class DefaultMainAccountTokensMigration(
}
var updatedMainAccount = mainAccount
val assignedTokensAccounts = notMainAccounts.mapNotNull { accountDTO ->
val assignedTokensAccounts = customAccounts.mapNotNull { accountDTO ->
val derivationIndex = accountDTO.derivationIndex.toDerivationIndex()
val tokensForAccount = unassignedTokens[derivationIndex]
if (tokensForAccount.isNullOrEmpty()) return@mapNotNull null

View file

@ -376,6 +376,7 @@ class DefaultMainAccountTokensMigrationTest {
derivationIndex = DerivationIndex.Main.value,
tokens = listOf(
createBitcoin(accountIndex = 0),
createBitcoin(accountIndex = 10),
),
)

View file

@ -85,7 +85,7 @@ internal class DefaultNewsRepository(
articlesToUpdate.map { article ->
val isViewed = viewedFlags[article.id] == true
article.copy(viewed = isViewed)
}.sortedBy { it.viewed }
}
}
.map<List<ShortArticle>, Either<Throwable, List<ShortArticle>>> { it.right() }
.catch { emit(it.left()) }

View file

@ -149,7 +149,7 @@ class DefaultGaslessTransactionRepository(
}
private companion object {
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("100000")
val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000")
val EMPTY_ADDRESSES = emptySet<String>()
}
}

View file

@ -9,7 +9,6 @@ import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.*
@ -23,7 +22,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
private val userWalletsListManager: UserWalletsListManager,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val onboardingRepository: OnboardingRepository,
) : TangemPayEligibilityManager {
@ -128,8 +126,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
}
private suspend fun checkTangemPayEligibility(): Boolean {
if (!tangemPayFeatureToggles.isEntryPointsEnabled) return true
return onboardingRepository.getCustomerEligibility() || onboardingRepository.checkCustomerEligibility()
}

View file

@ -13,7 +13,6 @@ import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import dagger.Binds
import dagger.Module
@ -78,14 +77,12 @@ internal interface TangemPayDataModule {
customerOrderRepository: CustomerOrderRepository,
tangemPayOnboardingRepository: OnboardingRepository,
eligibilityManager: TangemPayEligibilityManager,
tangemPayFeatureToggles: TangemPayFeatureToggles,
deviceSecurity: DeviceSecurityInfoProvider,
): TangemPayMainScreenCustomerInfoUseCase {
return TangemPayMainScreenCustomerInfoUseCase(
onboardingRepository = repository,
customerOrderRepository = customerOrderRepository,
eligibilityManager = eligibilityManager,
tangemPayFeatureToggles = tangemPayFeatureToggles,
deviceSecurity = deviceSecurity,
)
}

View file

@ -46,6 +46,7 @@ internal class TangemPayTxHistoryItemConverter(moshi: Moshi) :
merchantCategory = spend.merchantCategory,
status = TangemPayTxHistoryItemStatusConverter.convert(spend.status),
enrichedMerchantIconUrl = spend.enrichedMerchantIcon,
declinedReason = spend.declinedReason,
)
}

View file

@ -280,6 +280,12 @@ internal class DefaultWalletManagersFacade @Inject constructor(
),
)
val gaslessFeeAddresses = try {
gaslessTransactionRepository.getGaslessFeeAddresses()
} catch (error: Throwable) {
Timber.e(error, "Failed to load gasless fee addresses; falling back to empty set")
emptySet()
}
return when (itemsResult) {
is Result.Success -> PaginationWrapper(
currentPage = sdkPageConverter.convert(page),
@ -287,7 +293,7 @@ internal class DefaultWalletManagersFacade @Inject constructor(
items = SdkTransactionHistoryItemConverter(
smartContractMethods = readSmartContractMethods(),
yieldSupplyAddresses = YIELD_SUPPLY_ADDRESSES,
gaslessFeeAddresses = gaslessTransactionRepository.getGaslessFeeAddresses(),
gaslessFeeAddresses = gaslessFeeAddresses,
).convertList(itemsResult.data.items),
)
is Result.Failure -> error(itemsResult.error.message ?: itemsResult.error.customMessage)

View file

@ -27,9 +27,10 @@ import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
internal class DefaultYieldSupplyRepository(
private val yieldSupplyApi: YieldSupplyApi,
@ -40,7 +41,7 @@ internal class DefaultYieldSupplyRepository(
private val appPreferencesStore: AppPreferencesStore,
) : YieldSupplyRepository {
private val statusMap: MutableMap<String, YieldSupplyPendingStatus> = ConcurrentHashMap()
private val statusMapFlow = MutableStateFlow<Map<String, YieldSupplyPendingStatus>>(emptyMap())
override suspend fun getCachedMarkets(): List<YieldMarketToken>? = withContext(dispatchers.io) {
val cache = store.getSyncOrNull().orEmpty()
@ -129,10 +130,12 @@ internal class DefaultYieldSupplyRepository(
yieldSupplyPendingStatus: YieldSupplyPendingStatus?,
) {
val key = getTokenProtocolStatusKey(userWalletId, cryptoCurrency)
if (yieldSupplyPendingStatus != null) {
statusMap[key] = yieldSupplyPendingStatus
} else {
statusMap.remove(key)
statusMapFlow.update { currentMap ->
if (yieldSupplyPendingStatus != null) {
currentMap + (key to yieldSupplyPendingStatus)
} else {
currentMap - key
}
}
}
@ -153,7 +156,15 @@ internal class DefaultYieldSupplyRepository(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): YieldSupplyPendingStatus? {
return statusMap[getTokenProtocolStatusKey(userWalletId, cryptoCurrency)]
return statusMapFlow.value[getTokenProtocolStatusKey(userWalletId, cryptoCurrency)]
}
override fun getTokenProtocolPendingStatusFlow(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Flow<YieldSupplyPendingStatus?> {
val key = getTokenProtocolStatusKey(userWalletId, cryptoCurrency)
return statusMapFlow.map { it[key] }
}
private fun List<YieldMarketToken>.enrichNetworkIds(): List<YieldMarketToken> {

View file

@ -133,9 +133,10 @@ internal class TokenFeeCalculator(
is Fee.Ethereum.TokenCurrency -> raiseIllegalStateError("initialFee could only be native")
}
val feeInNativeCurrency = maxTokenFeeGas
.multiply(maxFeePerGas)
.multiply(GAS_PRICE_MULTIPLIER.toBigInteger())
val feeInNativeCurrency = BigDecimal(maxTokenFeeGas.multiply(maxFeePerGas))
.multiply(BigDecimal(GAS_PRICE_MULTIPLIER))
.setScale(0, RoundingMode.UP)
.toBigInteger()
val nativeFiatRate = nativeCurrencyStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null")
val tokenFiatRate = tokenForPayFeeStatus.value.fiatRate ?: raiseIllegalStateError("fiatRate is null")
@ -201,7 +202,7 @@ internal class TokenFeeCalculator(
/** Amount in token units for fee transfer */
const val FEE_TRANSFER_AMOUNT = 0.01 // calculate using decimals
/** Gas price safety multiplier for fee calculation */
const val GAS_PRICE_MULTIPLIER = 2
const val GAS_PRICE_MULTIPLIER = 1.5
const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1
const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10

View file

@ -363,7 +363,7 @@ class TokenFeeCalculatorTest {
// Expected
val expectedAmount = Amount(
value = BigDecimal("37.400000000000000000000000000000000000"),
value = BigDecimal("28.050000000000000000000000000000000000"),
token = Token(
name = "USDC",
symbol = "USDC",

View file

@ -29,6 +29,7 @@ sealed class TangemPayTxHistoryItem {
val merchantCategory: String?,
val status: Status,
val enrichedMerchantIconUrl: String?,
val declinedReason: String?,
) : TangemPayTxHistoryItem()
@Serializable

View file

@ -9,7 +9,6 @@ import com.tangem.domain.pay.model.*
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import kotlinx.coroutines.flow.*
@ -21,7 +20,6 @@ class TangemPayMainScreenCustomerInfoUseCase(
private val onboardingRepository: OnboardingRepository,
private val customerOrderRepository: CustomerOrderRepository,
private val eligibilityManager: TangemPayEligibilityManager,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
private val deviceSecurity: DeviceSecurityInfoProvider,
) {
@ -44,7 +42,7 @@ class TangemPayMainScreenCustomerInfoUseCase(
.fold(
ifLeft = { error ->
Timber.tag(TAG).e("Failed checkCustomerWallet for $userWalletId: ${error.javaClass.simpleName}")
if (error is VisaApiError.NotPaeraCustomer && tangemPayFeatureToggles.isEntryPointsEnabled) {
if (error is VisaApiError.NotPaeraCustomer) {
showOnboardingBannerIfEligible(userWalletId)
} else {
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
@ -62,13 +60,8 @@ class TangemPayMainScreenCustomerInfoUseCase(
.map(MainCustomerInfoContentState::Content)
updateState(userWalletId, result)
} else {
if (tangemPayFeatureToggles.isEntryPointsEnabled) {
// if there's no tangem pay, check eligibility and show onboarding banner
showOnboardingBannerIfEligible(userWalletId)
} else {
// ignore if there's no TangemPay
updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left())
}
// if there's no tangem pay, check eligibility and show onboarding banner
showOnboardingBannerIfEligible(userWalletId)
}
},
)

View file

@ -104,6 +104,18 @@ interface YieldSupplyRepository {
cryptoCurrency: CryptoCurrency,
): YieldSupplyPendingStatus?
/**
* Observe the pending status for the given wallet and currency as a [Flow].
*
* @param userWalletId the wallet to observe
* @param cryptoCurrency the currency or token to observe
* @return a [Flow] emitting the current pending status or null if none exists
*/
fun getTokenProtocolPendingStatusFlow(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Flow<YieldSupplyPendingStatus?>
fun getShouldShowYieldPromoBanner(): Flow<Boolean>
suspend fun setShouldShowYieldPromoBanner(shouldShow: Boolean)

View file

@ -0,0 +1,16 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
import kotlinx.coroutines.flow.Flow
class YieldSupplyEnterStatusFlowUseCase(
private val yieldSupplyRepository: YieldSupplyRepository,
) {
operator fun invoke(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Flow<YieldSupplyPendingStatus?> {
return yieldSupplyRepository.getTokenProtocolPendingStatusFlow(userWalletId, cryptoCurrency)
}
}

View file

@ -24,28 +24,24 @@ class YieldSupplyEnterStatusUseCase(
.toSet()
val hasPendingTx = status?.txIds?.any { it in pendingTxHashes } == true
if (hasPendingTx) {
status
val isActive = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true
val isExpired = status != null &&
System.currentTimeMillis() - status.createdAt > STATUS_EXPIRATION_MS
val shouldClearStatus = when {
isActive && status is YieldSupplyPendingStatus.Enter -> true
!isActive && status is YieldSupplyPendingStatus.Exit -> true
isExpired && !hasPendingTx -> true
else -> false
}
if (shouldClearStatus) {
yieldSupplyRepository.saveTokenProtocolPendingStatus(
userWalletId,
cryptoCurrencyStatus.currency,
null,
)
null
} else {
val isActive = cryptoCurrencyStatus.value.yieldSupplyStatus?.isActive == true
val isExpired = status != null &&
System.currentTimeMillis() - status.createdAt > STATUS_EXPIRATION_MS
val shouldClearStatus = when {
isExpired -> true
isActive && status is YieldSupplyPendingStatus.Exit -> false
!isActive && status is YieldSupplyPendingStatus.Enter -> false
else -> true
}
if (shouldClearStatus) {
yieldSupplyRepository.saveTokenProtocolPendingStatus(
userWalletId,
cryptoCurrencyStatus.currency,
null,
)
null
} else {
status
}
status
}
}
}

View file

@ -91,16 +91,13 @@ class YieldSupplyEnterStatusUseCaseTest {
coEvery {
yieldSupplyRepository.getPendingTxHashes(userWalletId, cryptoStatus.currency)
} returns emptyList()
coEvery {
yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null)
} returns Unit
val result = useCase(userWalletId, cryptoStatus)
assertThat(result.isRight()).isTrue()
val value = (result as Either.Right).value
assertThat(value).isNull()
coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, token, null) }
coVerify(exactly = 0) { yieldSupplyRepository.saveTokenProtocolPendingStatus(any(), any(), any()) }
}
@Test
@ -147,7 +144,7 @@ class YieldSupplyEnterStatusUseCaseTest {
@Test
fun `GIVEN exit status with pending tx WHEN invoke THEN returns status`() = runTest {
val token = createToken()
val cryptoStatus = createStatus(token)
val cryptoStatus = createStatus(token, isActive = true)
val pendingTxHash = "0xexit456"
val status = YieldSupplyPendingStatus.Exit(txIds = listOf(pendingTxHash))

View file

@ -35,7 +35,6 @@ import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
import com.tangem.features.details.utils.ItemsBuilder
import com.tangem.features.details.utils.SocialsBuilder
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.version.AppVersionProvider
import kotlinx.collections.immutable.ImmutableList
@ -71,7 +70,6 @@ internal class DetailsModel @Inject constructor(
private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val analyticsEventHandler: AnalyticsEventHandler,
private val tangemPayEligibilityManager: TangemPayEligibilityManager,
private val tangemPayFeatureToggles: TangemPayFeatureToggles,
) : Model() {
private val params: DetailsComponent.Params = paramsContainer.require()
@ -249,7 +247,6 @@ internal class DetailsModel @Inject constructor(
}
private fun addTangemPayItemIfEligible() {
if (!tangemPayFeatureToggles.isEntryPointsEnabled) return
modelScope.launch {
val isEligible = tangemPayEligibilityManager
.getEligibleWallets(shouldExcludePaeraCustomers = true)

View file

@ -16,15 +16,20 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -141,6 +146,7 @@ private fun ArticleDetail(
relatedTokensUM: RelatedTokensUM,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val density = LocalDensity.current
val background = LocalMainBottomSheetColor.current.value
val pagerHeight = 32.dp
@ -184,23 +190,21 @@ private fun ArticleDetail(
SpacerH(24.dp)
if (article.isLiked) {
PrimaryButtonIconStart(
modifier = Modifier.padding(horizontal = 16.dp),
iconResId = R.drawable.ic_heart_20,
text = stringResourceSafe(R.string.news_like),
size = TangemButtonSize.RoundedAction,
onClick = { onLikeClick() },
)
} else {
SecondaryButtonIconStart(
modifier = Modifier.padding(horizontal = 16.dp),
iconResId = R.drawable.ic_heart_20,
text = stringResourceSafe(R.string.news_like),
size = TangemButtonSize.RoundedAction,
onClick = { onLikeClick() },
)
}
SecondaryButtonIconStart(
modifier = Modifier.padding(horizontal = 16.dp),
iconResId = if (article.isLiked) {
R.drawable.ic_heart_filled_20
} else {
R.drawable.ic_heart_20
},
iconTint = Color.Unspecified,
text = stringResourceSafe(R.string.news_like),
size = TangemButtonSize.RoundedAction,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLikeClick()
},
)
RelatedTokensBlock(
relatedTokensUM = relatedTokensUM,
@ -375,16 +379,21 @@ private fun RelatedNewsItem(relatedArticle: RelatedArticleUM, modifier: Modifier
@Composable
private fun PreviewNewsDetailsContent() {
TangemThemePreview {
NewsDetailsContent(
state = NewsDetailsUM(
articlesStateUM = ArticlesStateUM.Content,
articles = MockArticlesFactory.createMockArticles(),
selectedArticleIndex = 0,
onShareClick = {},
onLikeClick = {},
onBackClick = {},
onArticleIndexChanged = {},
),
)
val background = TangemTheme.colors.background.tertiary
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember { mutableStateOf(background) },
) {
NewsDetailsContent(
state = NewsDetailsUM(
articlesStateUM = ArticlesStateUM.Content,
articles = MockArticlesFactory.createMockArticles(),
selectedArticleIndex = 0,
onShareClick = {},
onLikeClick = {},
onBackClick = {},
onArticleIndexChanged = {},
),
)
}
}
}

View file

@ -44,22 +44,30 @@ sealed class CommonSendAnalyticEvents(
data class FeeScreenOpened(
val categoryName: String,
val source: CommonSendSource,
val blockchain: String,
val token: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Fee Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
BLOCKCHAIN to blockchain,
TOKEN_PARAM to token,
),
)
data class FeeSummaryScreenOpened(
val categoryName: String,
val source: CommonSendSource,
val blockchain: String,
val token: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Fee Summary Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
BLOCKCHAIN to blockchain,
TOKEN_PARAM to token,
),
)
@ -67,12 +75,14 @@ sealed class CommonSendAnalyticEvents(
val categoryName: String,
val source: CommonSendSource,
val availableTokens: String,
val blockchain: String,
) : CommonSendAnalyticEvents(
category = categoryName,
event = "Fee Token Screen Opened",
params = mapOf(
SOURCE to source.analyticsName,
"Available Fee" to availableTokens,
BLOCKCHAIN to blockchain,
),
)

View file

@ -6,6 +6,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents.CommonSendSource
sealed class CommonSendFeeAnalyticEvents(
@ -38,11 +39,13 @@ sealed class CommonSendFeeAnalyticEvents(
data class CustomFeeButtonClicked(
override val categoryName: String,
val blockchain: String,
val token: String,
) : CommonSendFeeAnalyticEvents(
category = categoryName,
event = "Custom Fee Clicked",
params = mapOf(
BLOCKCHAIN to blockchain,
TOKEN_PARAM to token,
),
)

View file

@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
@Suppress("LongParameterList")
internal class FeeSelectorLogic @AssistedInject constructor(
@ -122,6 +123,7 @@ internal class FeeSelectorLogic @AssistedInject constructor(
CommonSendFeeAnalyticEvents.CustomFeeButtonClicked(
categoryName = params.analyticsCategoryName,
blockchain = params.cryptoCurrencyStatus.currency.network.name,
token = params.cryptoCurrencyStatus.currency.symbol,
),
)
}
@ -265,10 +267,21 @@ internal class FeeSelectorLogic @AssistedInject constructor(
private suspend fun populateExtendedFee(
fee: TransactionFeeExtended,
): Either<GetFeeError, LoadedFeeResult.Extended> = either {
val selectedToken = getSelectedTokenStatus(fee.feeTokenId).bind()
val availableTokens = getAvailableFeeTokens().fold(
ifLeft = { error ->
Timber.e("Failed to get available fee tokens: $error")
if (selectedToken.currency !is CryptoCurrency.Coin) {
raise(error)
}
emptyList()
},
ifRight = { it },
)
LoadedFeeResult.Extended(
fee = fee,
selectedToken = getSelectedTokenStatus(fee.feeTokenId).bind(),
availableTokens = getAvailableFeeTokens().bind(),
selectedToken = selectedToken,
availableTokens = availableTokens,
)
}

View file

@ -86,6 +86,8 @@ internal class FeeSelectorModel @Inject constructor(
CommonSendAnalyticEvents.FeeSummaryScreenOpened(
categoryName = params.analyticsCategoryName,
source = params.analyticsSendSource,
blockchain = params.cryptoCurrencyStatus.currency.network.name,
token = params.cryptoCurrencyStatus.currency.symbol,
),
)
}
@ -95,6 +97,8 @@ internal class FeeSelectorModel @Inject constructor(
CommonSendAnalyticEvents.FeeScreenOpened(
categoryName = params.analyticsCategoryName,
source = params.analyticsSendSource,
blockchain = params.cryptoCurrencyStatus.currency.network.name,
token = params.cryptoCurrencyStatus.currency.symbol,
),
)
}
@ -105,6 +109,7 @@ internal class FeeSelectorModel @Inject constructor(
categoryName = params.analyticsCategoryName,
source = params.analyticsSendSource,
availableTokens = getAvailableTokensString(),
blockchain = params.cryptoCurrencyStatus.currency.network.name,
),
)
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.ENS_ADDRESS
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.NONCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
@ -42,6 +43,7 @@ internal sealed class SendAnalyticEvents(
AnalyticsParam.EmptyFull.Full -> true.toString().capitalize()
}
put(ENS_ADDRESS, ensAddress)
put(FEE_TOKEN, feeToken)
},
), AppsFlyerIncludedEvent

View file

@ -2,12 +2,14 @@ package com.tangem.feature.swap.analytics
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.FeeType
@ -20,9 +22,15 @@ sealed class SwapEvents(
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(SWAP_CATEGORY, event, params) {
data class SwapScreenOpened(val token: String) : SwapEvents(
data class SwapScreenOpened(
val token: String,
val blockchain: String,
) : SwapEvents(
event = "Swap Screen Opened",
params = mapOf("Token" to token),
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
), AppsFlyerIncludedEvent
class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")

View file

@ -330,7 +330,12 @@ internal class SwapModel @Inject constructor(
}
}
analyticsEventHandler.send(SwapEvents.SwapScreenOpened(initialCurrencyFrom.symbol))
analyticsEventHandler.send(
SwapEvents.SwapScreenOpened(
token = initialCurrencyFrom.symbol,
blockchain = initialCurrencyFrom.network.name,
),
)
getBalanceHidingSettingsUseCase()
.onEach { settings ->

View file

@ -2,5 +2,4 @@ package com.tangem.features.tangempay
interface TangemPayFeatureToggles {
val isTangemPayEnabled: Boolean
val isEntryPointsEnabled: Boolean
}

View file

@ -7,6 +7,4 @@ internal class DefaultTangemPayFeatureToggles(
) : TangemPayFeatureToggles {
override val isTangemPayEnabled
get() = featureTogglesManager.isFeatureEnabled("TANGEM_PAY_ENABLED")
override val isEntryPointsEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("TANGEM_PAY_ENTRYPOINT_ENABLED")
}

View file

@ -17,10 +17,17 @@ internal data class TangemPayTxHistoryDetailsUM(
val transactionAmountColor: ColorReference,
val localTransactionText: String?,
val labelState: LabelUM?,
val notification: NotificationConfig?,
val notification: NotificationState?,
val buttons: ImmutableList<ButtonState>,
val dismiss: () -> Unit,
) {
data class NotificationState(
val config: NotificationConfig,
val titleColor: ColorReference,
val iconTint: ColorReference,
val containerColor: ColorReference?,
)
data class ButtonState(val text: TextReference, val onClick: () -> Unit, val startIcon: ImageReference.Res? = null)
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.tangempay.model.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.features.tangempay.details.impl.R
import com.tangem.utils.converter.Converter
internal object PayDetailsSpendSubtitleConverter : Converter<TangemPayTxHistoryItem.Spend, TextReference> {
override fun convert(value: TangemPayTxHistoryItem.Spend): TextReference {
val merchantCategory = value.merchantCategory
val enrichedMerchantCategory = value.enrichedMerchantCategory
val merchantCategoryCode = value.merchantCategoryCode
val categoryCodeText = if (!merchantCategoryCode.isNullOrEmpty()) {
resourceReference(
id = R.string.tangem_pay_history_item_spend_mcc,
formatArgs = wrappedList(merchantCategoryCode),
)
} else {
null
}
val categoryText = when {
// if merchantCategory isNotEmpty use this
!merchantCategory.isNullOrEmpty() -> stringReference(merchantCategory)
// If merchantCategory empty or null but enrichedMerchantCategory is not empty
!enrichedMerchantCategory.isNullOrEmpty() -> stringReference(enrichedMerchantCategory)
else -> resourceReference(R.string.tangem_pay_other)
}
return if (categoryCodeText != null) {
resourceReference(
id = R.string.tangem_pay_history_item_spend_mc_title_format,
formatArgs = wrappedList(categoryText, categoryCodeText),
)
} else {
categoryText
}
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
@ -24,7 +25,7 @@ internal object TangemPayTxHistoryDetailsConverter :
Converter<TangemPayTxHistoryDetailsConverter.Input, TangemPayTxHistoryDetailsUM> {
private val dateFormatter = DateTimeFormatters.getBestFormatterBySkeleton("dd MMMM")
private val paySpendSubtitleConverter = PaySpendSubtitleConverter
private val paySpendSubtitleConverter = PayDetailsSpendSubtitleConverter
override fun convert(value: Input): TangemPayTxHistoryDetailsUM {
val transaction = value.item
@ -140,13 +141,16 @@ internal object TangemPayTxHistoryDetailsConverter :
private fun TangemPayTxHistoryItem.extractAmountColor(): ColorReference {
return when (this) {
is TangemPayTxHistoryItem.Fee,
is TangemPayTxHistoryItem.Spend,
is TangemPayTxHistoryItem.Payment,
-> themedColor { TangemTheme.colors.text.primary1 }
is TangemPayTxHistoryItem.Collateral -> when (this.type) {
TangemPayTxHistoryItem.Type.Deposit -> themedColor { TangemTheme.colors.text.accent }
TangemPayTxHistoryItem.Type.Withdrawal -> themedColor { TangemTheme.colors.text.primary1 }
}
is TangemPayTxHistoryItem.Spend -> when (this.status) {
TangemPayTxHistoryItem.Status.DECLINED -> themedColor { TangemTheme.colors.text.warning }
else -> themedColor { TangemTheme.colors.text.primary1 }
}
}
}
@ -200,21 +204,39 @@ internal object TangemPayTxHistoryDetailsConverter :
}
}
private fun TangemPayTxHistoryItem.extractNotification(): NotificationConfig? {
private fun TangemPayTxHistoryItem.extractNotification(): TangemPayTxHistoryDetailsUM.NotificationState? {
return when (this) {
is TangemPayTxHistoryItem.Payment -> null
is TangemPayTxHistoryItem.Collateral -> null
is TangemPayTxHistoryItem.Fee -> NotificationConfig(
title = resourceReference(R.string.tangem_pay_transaction_fee_notification_text),
subtitle = TextReference.EMPTY,
iconResId = R.drawable.ic_token_info_24,
)
is TangemPayTxHistoryItem.Spend -> when (this.status) {
TangemPayTxHistoryItem.Status.DECLINED -> NotificationConfig(
title = resourceReference(R.string.tangem_pay_transaction_declined_notification_text),
is TangemPayTxHistoryItem.Fee -> TangemPayTxHistoryDetailsUM.NotificationState(
config = NotificationConfig(
title = resourceReference(R.string.tangem_pay_transaction_fee_notification_text),
subtitle = TextReference.EMPTY,
iconResId = R.drawable.ic_token_info_24,
)
),
titleColor = themedColor { TangemTheme.colors.text.tertiary },
iconTint = themedColor { TangemTheme.colors.icon.secondary },
containerColor = null,
)
is TangemPayTxHistoryItem.Spend -> when (this.status) {
TangemPayTxHistoryItem.Status.DECLINED ->
TangemPayTxHistoryDetailsUM.NotificationState(
config = NotificationConfig(
title = if (declinedReason.isNullOrEmpty()) {
resourceReference(R.string.tangem_pay_transaction_declined_notification_text)
} else {
resourceReference(
id = R.string.tangem_pay_history_item_spend_mc_declined_reason,
formatArgs = wrappedList(requireNotNull(declinedReason)),
)
},
subtitle = TextReference.EMPTY,
iconResId = R.drawable.ic_token_info_24,
),
titleColor = themedColor { TangemTheme.colors.text.warning },
iconTint = themedColor { TangemTheme.colors.icon.warning },
containerColor = themedColor { TangemColorPalette.Amaranth.copy(alpha = 0.1F) },
)
TangemPayTxHistoryItem.Status.PENDING,
TangemPayTxHistoryItem.Status.COMPLETED,
TangemPayTxHistoryItem.Status.RESERVED,

View file

@ -37,10 +37,10 @@ internal class TangemPayTxHistoryItemsConverter(
val localDate = spend.date.withZone(DateTimeZone.getDefault())
val amountPrefix = when {
spend.amount.isZero() -> ""
spend.status == TangemPayTxHistoryItem.Status.DECLINED -> ""
else -> StringsSigns.MINUS
spend.status == TangemPayTxHistoryItem.Status.DECLINED || spend.amount.isPositive() -> StringsSigns.MINUS
else -> StringsSigns.PLUS
}
val amount = amountPrefix + spend.amount.format {
val amount = amountPrefix + spend.amount.abs().format {
fiat(fiatCurrencyCode = spend.currency.currencyCode, fiatCurrencySymbol = spend.currency.symbol)
}
return TangemPayTransactionState.Content.Spend(
@ -48,8 +48,9 @@ internal class TangemPayTxHistoryItemsConverter(
onClick = { txHistoryUiActions.onTransactionClick(spend) },
amount = amount,
amountColor = themedColor {
when (spend.status) {
TangemPayTxHistoryItem.Status.DECLINED -> TangemTheme.colors.text.warning
when {
spend.status == TangemPayTxHistoryItem.Status.DECLINED -> TangemTheme.colors.text.warning
amountPrefix == StringsSigns.PLUS -> TangemTheme.colors.text.accent
else -> TangemTheme.colors.text.primary1
}
},

View file

@ -26,6 +26,7 @@ import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.tangempay.details.impl.R
@ -83,21 +84,24 @@ internal fun TangemPayTxHistoryDetailsContent(state: TangemPayTxHistoryDetailsUM
style = TangemTheme.typography.head,
color = state.transactionAmountColor.resolveReference(),
)
state.localTransactionText?.let { localTransaction ->
if (state.localTransactionText != null) {
Text(
modifier = Modifier.padding(top = 4.dp),
text = localTransaction.orMaskWithStars(state.isBalanceHidden),
text = state.localTransactionText.orMaskWithStars(state.isBalanceHidden),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
state.labelState?.let { Label(state = state.labelState, modifier = Modifier.padding(top = 12.dp)) }
if (state.labelState != null) {
Label(state = state.labelState, modifier = Modifier.padding(top = 12.dp))
}
SpacerH32()
state.notification?.let {
if (state.notification != null) {
Notification(
config = state.notification,
titleColor = TangemTheme.colors.text.tertiary,
iconTint = TangemTheme.colors.icon.secondary,
config = state.notification.config,
titleColor = state.notification.titleColor.resolveReference(),
iconTint = state.notification.iconTint.resolveReference(),
containerColor = state.notification.containerColor?.resolveReference(),
)
}
ButtonsContainer(
@ -195,10 +199,15 @@ private class TangemPayTxHistoryDetailsUMProvider : CollectionPreviewParameterPr
text = resourceReference(R.string.tangem_pay_status_declined),
style = LabelStyle.WARNING,
),
notification = NotificationConfig(
title = stringReference("The bank rejected this transaction request."),
subtitle = TextReference.EMPTY,
iconResId = R.drawable.ic_token_info_24,
notification = TangemPayTxHistoryDetailsUM.NotificationState(
config = NotificationConfig(
title = stringReference("The bank rejected this transaction request."),
subtitle = TextReference.EMPTY,
iconResId = R.drawable.ic_token_info_24,
),
titleColor = themedColor { TangemTheme.colors.text.warning },
iconTint = themedColor { TangemTheme.colors.icon.warning },
containerColor = themedColor { TangemColorPalette.Amaranth.copy(alpha = 0.1F) },
),
buttons = persistentListOf(
TangemPayTxHistoryDetailsUM.ButtonState(
@ -240,10 +249,15 @@ private class TangemPayTxHistoryDetailsUMProvider : CollectionPreviewParameterPr
transactionAmountColor = themedColor { TangemTheme.colors.text.primary1 },
localTransactionText = null,
labelState = null,
notification = NotificationConfig(
title = stringReference("This fee goes to cover the cost of handling your transfer."),
subtitle = TextReference.EMPTY,
iconResId = R.drawable.ic_token_info_24,
notification = TangemPayTxHistoryDetailsUM.NotificationState(
config = NotificationConfig(
title = stringReference("This fee goes to cover the cost of handling your transfer."),
subtitle = TextReference.EMPTY,
iconResId = R.drawable.ic_token_info_24,
),
titleColor = themedColor { TangemTheme.colors.text.tertiary },
iconTint = themedColor { TangemTheme.colors.icon.secondary },
containerColor = null,
),
buttons = persistentListOf(
TangemPayTxHistoryDetailsUM.ButtonState(

View file

@ -65,6 +65,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.core.ui.test.MarketTooltipTestTags
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
import com.tangem.core.ui.utils.lineTo
import com.tangem.core.ui.utils.moveTo
import com.tangem.core.ui.utils.toPx
@ -180,79 +181,81 @@ private fun WalletContent(
)
} ?: PaddingValues(bottom = TangemTheme.dimens.spacing92 + bottomBarHeight)
LazyColumn(
modifier = Modifier.testTag(MainScreenTestTags.SCREEN_CONTAINER),
state = listState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
) {
item(
// !!! Type of the key should be saveable via Bundle on Android !!!
key = state.wallets.map { it.walletCardState.id.stringValue },
contentType = state.wallets.map { it.walletCardState.id },
TangemSharedTransitionLayout {
LazyColumn(
modifier = Modifier.testTag(MainScreenTestTags.SCREEN_CONTAINER),
state = listState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
) {
WalletsList(
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
lazyListState = walletsListState,
wallets = state.wallets.map(WalletState::walletCardState).toImmutableList(),
isBalanceHidden = state.isHidingMode,
)
}
when (selectedWallet) {
is WalletState.MultiCurrency -> {
actions(
actions = selectedWallet.buttons,
selectedWalletIndex = selectedWalletIndex,
modifier = movableItemModifier.padding(top = betweenItemsPadding),
)
}
is WalletState.SingleCurrency -> {
lazyActions(
actions = selectedWallet.buttons,
selectedWalletIndex = selectedWalletIndex,
modifier = movableItemModifier.padding(top = betweenItemsPadding),
)
}
}
notifications(configs = selectedWallet.warnings, modifier = itemModifier)
if (selectedWallet is WalletState.MultiCurrency) {
item(
key = "TangemPayMainScreenBlock",
contentType = selectedWallet.tangemPayState::class.java,
// !!! Type of the key should be saveable via Bundle on Android !!!
key = state.wallets.map { it.walletCardState.id.stringValue },
contentType = state.wallets.map { it.walletCardState.id },
) {
TangemPayMainScreenBlock(
state = selectedWallet.tangemPayState,
WalletsList(
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
lazyListState = walletsListState,
wallets = state.wallets.map(WalletState::walletCardState).toImmutableList(),
isBalanceHidden = state.isHidingMode,
modifier = itemModifier,
)
}
}
(selectedWallet as? WalletState.SingleCurrency)?.let { walletState ->
walletState.marketPriceBlockState?.let { marketPriceBlockState ->
marketPriceBlock(state = marketPriceBlockState, modifier = itemModifier)
when (selectedWallet) {
is WalletState.MultiCurrency -> {
actions(
actions = selectedWallet.buttons,
selectedWalletIndex = selectedWalletIndex,
modifier = movableItemModifier.padding(top = betweenItemsPadding),
)
}
is WalletState.SingleCurrency -> {
lazyActions(
actions = selectedWallet.buttons,
selectedWalletIndex = selectedWalletIndex,
modifier = movableItemModifier.padding(top = betweenItemsPadding),
)
}
}
if (walletState is WalletState.SingleCurrency.Content) {
expressTransactionsItems(
expressTxs = walletState.expressTxsToDisplay,
modifier = itemModifier,
)
notifications(configs = selectedWallet.warnings, modifier = itemModifier)
if (selectedWallet is WalletState.MultiCurrency) {
item(
key = "TangemPayMainScreenBlock",
contentType = selectedWallet.tangemPayState::class.java,
) {
TangemPayMainScreenBlock(
state = selectedWallet.tangemPayState,
isBalanceHidden = state.isHidingMode,
modifier = itemModifier,
)
}
}
(selectedWallet as? WalletState.SingleCurrency)?.let { walletState ->
walletState.marketPriceBlockState?.let { marketPriceBlockState ->
marketPriceBlock(state = marketPriceBlockState, modifier = itemModifier)
}
if (walletState is WalletState.SingleCurrency.Content) {
expressTransactionsItems(
expressTxs = walletState.expressTxsToDisplay,
modifier = itemModifier,
)
}
}
contentItems(
state = selectedWallet,
txHistoryItems = txHistoryItems,
isBalanceHidden = state.isHidingMode,
modifier = movableItemModifier,
)
nftCollections(state = selectedWallet, itemModifier = itemModifier)
organizeTokens(state = selectedWallet, itemModifier = itemModifier)
}
contentItems(
state = selectedWallet,
txHistoryItems = txHistoryItems,
isBalanceHidden = state.isHidingMode,
modifier = movableItemModifier,
)
nftCollections(state = selectedWallet, itemModifier = itemModifier)
organizeTokens(state = selectedWallet, itemModifier = itemModifier)
}
ShowBottomSheet(bottomSheetConfig = selectedWallet.bottomSheetConfig)

View file

@ -84,7 +84,7 @@ internal fun LazyListScope.portfolioTokensList(
}
itemsIndexed(
items = tokens,
key = { _, item -> item.id },
key = { _, item -> item.id.toString() + "-portfolio-${portfolio.id}" },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, token ->
val indexWithHeader = tokenIndex.inc()

View file

@ -31,8 +31,6 @@ import com.tangem.features.yield.supply.impl.R
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer
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 kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -57,6 +55,7 @@ internal class YieldSupplyModel @Inject constructor(
private val yieldSupplyActivateUseCase: YieldSupplyActivateUseCase,
private val yieldSupplyDeactivateUseCase: YieldSupplyDeactivateUseCase,
private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase,
private val yieldSupplyEnterStatusFlowUseCase: YieldSupplyEnterStatusFlowUseCase,
private val yieldSupplyMinAmountUseCase: YieldSupplyMinAmountUseCase,
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase,
) : Model(), YieldSupplyClickIntents {
@ -71,8 +70,6 @@ internal class YieldSupplyModel @Inject constructor(
var userWallet: UserWallet by Delegates.notNull()
private var latestCryptoCurrencyStatus: CryptoCurrencyStatus? = null
private val loadStatusJobHolder = JobHolder()
private val isFirstCryptoCurrencyStatusEmission = AtomicBoolean(true)
init {
@ -84,7 +81,7 @@ internal class YieldSupplyModel @Inject constructor(
appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }
val isAvailable = yieldSupplyIsAvailableUseCase(params.userWalletId, params.cryptoCurrency)
if (isAvailable) {
subscribeOnCurrencyStatusUpdates()
loadUserWalletData()
singleNetworkStatusFetcher(
params = SingleNetworkStatusFetcher.Params(
userWalletId = params.userWalletId,
@ -95,30 +92,12 @@ internal class YieldSupplyModel @Inject constructor(
}
}
private fun subscribeOnCurrencyStatusUpdates() {
private fun loadUserWalletData() {
modelScope.launch {
getUserWalletUseCase(params.userWalletId).fold(
ifRight = { wallet ->
userWallet = wallet
getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = params.userWalletId,
currencyId = cryptoCurrency.id,
isSingleWalletWithTokens = false,
).onEach { maybeCryptoCurrency ->
maybeCryptoCurrency.fold(
ifRight = { cryptoCurrencyStatus ->
latestCryptoCurrencyStatus = cryptoCurrencyStatus
if (isFirstCryptoCurrencyStatusEmission.compareAndSet(true, false)) {
sendInfoAboutProtocolStatus(cryptoCurrencyStatus)
}
onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus)
},
ifLeft = {
Timber.w(it.toString())
},
)
}.launchIn(modelScope)
subscribeOnCurrencyStatusUpdates()
},
ifLeft = {
Timber.w(it.toString())
@ -128,6 +107,36 @@ internal class YieldSupplyModel @Inject constructor(
}
}
private fun subscribeOnCurrencyStatusUpdates() {
combine(
getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = params.userWalletId,
currencyId = cryptoCurrency.id,
isSingleWalletWithTokens = false,
),
yieldSupplyEnterStatusFlowUseCase(
userWalletId = params.userWalletId,
cryptoCurrency = cryptoCurrency,
),
) { maybeCryptoCurrency, _ ->
maybeCryptoCurrency
}.flowOn(dispatchers.io)
.onEach { maybeCryptoCurrency ->
maybeCryptoCurrency.fold(
ifRight = { cryptoCurrencyStatus ->
latestCryptoCurrencyStatus = cryptoCurrencyStatus
if (isFirstCryptoCurrencyStatusEmission.compareAndSet(true, false)) {
sendInfoAboutProtocolStatus(cryptoCurrencyStatus)
}
onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus)
},
ifLeft = {
Timber.w(it.toString())
},
)
}.launchIn(modelScope)
}
private suspend fun loadTokenStatus() {
val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return
yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken)
@ -168,13 +177,11 @@ internal class YieldSupplyModel @Inject constructor(
)
}
private fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) = modelScope.launch(
dispatchers.default,
) {
private suspend fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) {
val isCryptoCurrencyStatusFromCache = cryptoCurrencyStatus.value.sources.networkSource != StatusSource.ACTUAL
val processing = uiState.value is YieldSupplyUM.Processing
if (isCryptoCurrencyStatusFromCache && processing) {
return@launch
return
}
val pendingStatus = yieldSupplyEnterStatusUseCase(
@ -187,7 +194,7 @@ internal class YieldSupplyModel @Inject constructor(
} else {
loadStatus(cryptoCurrencyStatus)
}
}.saveIn(loadStatusJobHolder)
}
private fun showProcessing(status: YieldSupplyPendingStatus) {
uiState.update {

@ -1 +1 @@
Subproject commit cfc2c0ab47e2e821f38f463f32f120d620804c72
Subproject commit ab6aee1d49ac0cfca644330a525dced725d5a257