Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 20:05:51 +03:00
commit d0b35bc331
680 changed files with 26556 additions and 2709 deletions

View file

@ -112,6 +112,7 @@ dependencies {
implementation(projects.features.sendV2.api)
implementation(projects.features.tokenRecieve.api)
implementation(projects.features.yieldSupply.api)
implementation(projects.features.commonFeatures.api)
implementation(deps.decompose.ext.compose)

View file

@ -21,8 +21,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.AddFundsBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
@ -47,6 +47,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyWarningComponentFactory: YieldSupplyDepositedWarningComponent.Factory,
private val addFundsComponentFactory: AddFundsComponent.Factory,
yieldSupplyComponentFactory: YieldSupplyComponent.Factory,
private val ratingComponentFactory: RatingComponent.Factory,
) : TokenDetailsComponent, AppComponentContext by appComponentContext {
@ -177,9 +178,15 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
dynamicAddressesDelegate = model.dynamicAddressesDelegate,
onDismiss = model.bottomSheetNavigation::dismiss,
)
is TokenDetailsBottomSheetConfig.AddFunds -> AddFundsBottomSheetComponent(
stateFlow = model.addFundsUiState,
onDismiss = model.bottomSheetNavigation::dismiss,
is TokenDetailsBottomSheetConfig.AddFunds -> addFundsComponentFactory.create(
context = childByContext(componentContext),
params = AddFundsComponent.Params(
launchMode = AddFundsComponent.LaunchMode.TokenActionsOnly(
userWalletId = route.userWalletId,
currency = route.currency,
),
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
is TokenDetailsBottomSheetConfig.Transfer -> TransferBottomSheetComponent(
stateFlow = model.transferUiState,

View file

@ -0,0 +1,15 @@
package com.tangem.feature.tokendetails
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import javax.inject.Inject
internal class DefaultTokenDetailsFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : TokenDetailsFeatureToggles {
override val isQuickTopUpEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15258_QUICK_TOP_UP_ENABLED,
)
}

View file

@ -0,0 +1,21 @@
package com.tangem.feature.tokendetails.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.feature.tokendetails.DefaultTokenDetailsFeatureToggles
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object TokenDetailsFeatureModule {
@Provides
@Singleton
fun provideTokenDetailsFeatureToggles(featureTogglesManager: FeatureTogglesManager): TokenDetailsFeatureToggles {
return DefaultTokenDetailsFeatureToggles(featureTogglesManager)
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig
import java.math.BigDecimal
@Suppress("TooManyFunctions")
interface TokenDetailsClickIntents {
@ -71,6 +72,8 @@ interface TokenDetailsClickIntents {
fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig)
fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String)
fun onYieldInfoClick()
// region Clore migration
@ -168,6 +171,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onYieldInfoClick() { /* no op */ }
override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { /* no op */ }
override fun onCopyAddress(): TextReference? {
/* no op */
return null

View file

@ -64,6 +64,7 @@ import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.CheckOnrampAvailabilityUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.staking.GetStakingAvailabilityUseCase
import com.tangem.domain.staking.GetStakingEntryInfoUseCase
@ -84,6 +85,7 @@ import com.tangem.domain.transaction.error.OpenTrustlineError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
@ -103,6 +105,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.QuickTopUpBlockFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
@ -132,6 +135,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration")
@ -192,6 +196,9 @@ internal class TokenDetailsModel @Inject constructor(
private val redesignStateController: TokenDetailsStateController,
private val swapFeedbackUseCase: SwapFeedbackUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val quickTopUpBlockFactory: QuickTopUpBlockFactory,
private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
) : Model(),
TokenDetailsClickIntents,
YieldSupplyDepositedWarningComponent.ModelCallback {
@ -304,6 +311,7 @@ internal class TokenDetailsModel @Inject constructor(
handleBalanceHiding()
checkForActionUpdates()
handleNavigationParam()
observeQuickTopUpBlock()
}
private fun initButtons() {
@ -551,7 +559,12 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onAddFundsClick() {
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.AddFunds)
bottomSheetNavigation.activate(
TokenDetailsBottomSheetConfig.AddFunds(
userWalletId = userWalletId,
currency = cryptoCurrency,
),
)
}
override fun onTransferClick() {
@ -584,6 +597,43 @@ internal class TokenDetailsModel @Inject constructor(
}
}
override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonQuickTopUp(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
currency = currencyCode,
value = amount.toInt().toString(),
),
)
appRouter.push(
AppRoute.Onramp(
source = OnrampSource.TOKEN_DETAILS,
userWalletId = userWalletId,
currency = cryptoCurrency,
initialFiatAmount = amount,
),
)
}
private fun onQuickTopUpOtherClick() {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
status = ScenarioUnavailabilityReason.None.toReasonAnalyticsText(),
derivationIndex = getAccountIndexOrNull(),
),
)
appRouter.push(
AppRoute.Onramp(
source = OnrampSource.TOKEN_DETAILS,
userWalletId = userWalletId,
currency = cryptoCurrency,
),
)
}
override fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy(
@ -1363,6 +1413,38 @@ internal class TokenDetailsModel @Inject constructor(
observeRedesignStakingNotification()
}
private fun observeQuickTopUpBlock() {
getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }
.distinctUntilChanged()
.flatMapLatest { status ->
flow {
val amount = status.value.amount
if (amount == null || !amount.isZero()) {
emit(null)
return@flow
}
val txCount = getTxHistoryItemsCountUseCase(userWalletId, cryptoCurrency)
val availability = checkOnrampAvailabilityUseCase(userWallet)
emit(
quickTopUpBlockFactory.build(
currencyStatus = status,
isTxHistoryEmpty = txCount,
onrampAvailability = availability,
onPresetClick = ::onQuickTopUpClick,
onOtherClick = ::onQuickTopUpOtherClick,
),
)
}
}
.onEach { block ->
uiState.value = uiState.value.copy(quickTopUpBlock = block)
redesignStateController.update { state -> state.copy(quickTopUpBlock = block) }
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun observeRedesignStakingNotification() {
val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }

View file

@ -4,6 +4,7 @@ import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.details.TokenAction
import kotlinx.serialization.Serializable
@ -32,7 +33,10 @@ sealed class TokenDetailsBottomSheetConfig : Route {
data object DynamicAddresses : TokenDetailsBottomSheetConfig()
@Serializable
data object AddFunds : TokenDetailsBottomSheetConfig()
data class AddFunds(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
) : TokenDetailsBottomSheetConfig()
@Serializable
data object Transfer : TokenDetailsBottomSheetConfig()

View file

@ -0,0 +1,17 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class QuickTopUpBlockUM(
val amounts: ImmutableList<QuickTopUpAmountUM>,
) {
@Immutable
data class QuickTopUpAmountUM(
val displayValue: TextReference,
val onClick: () -> Unit,
val isOther: Boolean = false,
)
}

View file

@ -15,4 +15,5 @@ internal data class TokenDetailsState(
val pullToRefreshConfig: PullToRefreshConfig,
val isBalanceHidden: Boolean,
val isMarketPriceAvailable: Boolean,
val quickTopUpBlock: QuickTopUpBlockUM? = null,
)

View file

@ -24,6 +24,7 @@ internal data class TokenDetailsUM(
val addFundsUM: AddFundsUM,
val transferUM: TransferUM,
val zeroBalanceActionsUM: ZeroBalanceActionsUM,
val quickTopUpBlock: QuickTopUpBlockUM? = null,
)
@Immutable

View file

@ -0,0 +1,79 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import arrow.core.Either
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import javax.inject.Inject
internal class QuickTopUpBlockFactory @Inject constructor(
private val featureToggles: TokenDetailsFeatureToggles,
) {
fun build(
currencyStatus: CryptoCurrencyStatus,
isTxHistoryEmpty: Either<TxHistoryStateError, Int>,
onrampAvailability: Either<OnrampError, OnrampAvailability>,
onPresetClick: (BigDecimal, String) -> Unit,
onOtherClick: () -> Unit,
): QuickTopUpBlockUM? {
if (!featureToggles.isQuickTopUpEnabled) return null
val amount = currencyStatus.value.amount
if (amount == null || !amount.isZero()) return null
val isHistoryEmpty = isTxHistoryEmpty.fold(
ifLeft = { it is TxHistoryStateError.EmptyTxHistories },
ifRight = { it == 0 },
)
if (!isHistoryEmpty) return null
val currency = when (val availability = onrampAvailability.getOrNull()) {
is OnrampAvailability.Available -> availability.currency
is OnrampAvailability.ConfirmResidency -> {
if (!availability.country.onrampAvailable) return null
availability.country.defaultCurrency
}
else -> return null
}
val presets = when (currency.code) {
USD_CODE -> USD_PRESETS
EUR_CODE -> EUR_PRESETS
else -> return null
}
val presetAmounts = presets.map { value ->
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("${currency.unit}$value"),
onClick = { onPresetClick(BigDecimal(value), currency.code) },
)
}
val otherAmount = QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = resourceReference(R.string.quick_top_up_chip_other),
onClick = onOtherClick,
isOther = true,
)
return QuickTopUpBlockUM(
amounts = (presetAmounts + otherAmount).toImmutableList(),
)
}
private companion object {
const val USD_CODE = "USD"
const val EUR_CODE = "EUR"
val USD_PRESETS = listOf(50, 200, 700)
val EUR_PRESETS = listOf(50, 200, 650)
}
}

View file

@ -8,12 +8,7 @@ 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.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.defaultAmount
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -61,12 +56,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.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Disabled,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.staking_notification_network_error_text),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = null,
@ -122,12 +117,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.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stakeAvailableSubtitle(availability.option.displayRewardInfo),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
@ -170,7 +165,7 @@ 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.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = getRewardSubtitle(status, rewardFiatAmount),
@ -257,7 +252,7 @@ internal class UpdateStakingNotificationTransformer(
return EarnBlockUM.SubtitleUM.Text(
text = text,
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = if (isAccent) EarnBlockUM.SubtitleUM.Tone.Accent else EarnBlockUM.SubtitleUM.Tone.Disabled,
)
}

View file

@ -0,0 +1,135 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.utils.StringsSigns
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM
import kotlinx.collections.immutable.persistentListOf
private val quickTopUpGradientBrush = Brush.linearGradient(
colors = listOf(Color(0xFFEDE5F3), Color(0xFFD7EDD9)),
start = Offset(0f, 0f),
end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
)
private val quickTopUpBorderBrush = Brush.sweepGradient(
listOf(
Color(0x0D000000),
Color(0x26000000),
Color(0x0D000000),
Color(0x26000000),
),
)
@Composable
internal fun QuickTopUpBlock(state: QuickTopUpBlockUM, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(TangemTheme.dimens.radius20)
Box(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(brush = quickTopUpGradientBrush)
.border(width = 1.dp, brush = quickTopUpBorderBrush, shape = shape),
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
val textColor = TangemTheme.colors.text.primary1
Text(
text = combinedReference(
stringReference("${StringsSigns.LIGHTNING} "),
resourceReference(R.string.quick_top_up_title),
).resolveReference(),
style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.graphicsLayer {
colorFilter = ColorFilter.tint(textColor, BlendMode.SrcIn)
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6),
) {
state.amounts.fastForEach { amountUM ->
Button(
onClick = amountUM.onClick,
shape = CircleShape,
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing0,
),
colors = ButtonDefaults.buttonColors(
containerColor = TangemTheme.colors.background.primary,
contentColor = TangemTheme.colors.text.primary1,
),
modifier = Modifier.heightIn(TangemTheme.dimens.size36),
elevation = null,
) {
Text(
text = amountUM.displayValue.resolveReference(),
style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold),
)
}
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable
private fun QuickTopUpBlock_Preview() {
TangemThemePreviewRedesign {
QuickTopUpBlock(
state = QuickTopUpBlockUM(
amounts = persistentListOf(
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$50"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$200"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$700"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = resourceReference(R.string.quick_top_up_chip_other),
onClick = {},
isOther = true,
),
),
),
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
)
}
}

View file

@ -2,60 +2,47 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import com.tangem.common.ui.earn.EarnBlock
import com.tangem.common.ui.notifications.notifications
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.earn.EarnBlock
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState
import com.tangem.common.ui.notifications.notifications
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.topFade
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.*
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.ZeroBalanceActionsBlock
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
@ -64,6 +51,7 @@ import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
@ -72,6 +60,9 @@ import kotlinx.coroutines.flow.StateFlow
private val TopBarHeight: Dp = 64.dp
private val MarketBlockHorizontalPadding: Dp = 14.dp
private const val TOP_FADE_MID_STOP = 0.8f
private const val TOP_FADE_MID_ALPHA = 0.8f
@Composable
internal fun TokenDetailsScreen(
tokenDetailsUM: TokenDetailsUM,
@ -84,20 +75,17 @@ internal fun TokenDetailsScreen(
val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle()
val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() }
val topBarTotalHeight = TopBarHeight + statusBarHeight
val hazeState = rememberHazeState()
val rootBackground = TangemTheme.colors2.surface.level2
var marketBlockHeight by remember { mutableStateOf(0.dp) }
val effectiveBottomPadding = marketBlockHeight + TangemTheme.dimens2.x4
Box(
modifier = modifier
.fillMaxSize()
.background(rootBackground),
) {
CompositionLocalProvider(LocalHazeState provides hazeState) {
Box(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = -2f),
.background(rootBackground),
) {
TangemPullToRefreshSlidingContainer(
config = tokenDetailsUM.pullToRefreshConfig,
@ -115,27 +103,17 @@ internal fun TokenDetailsScreen(
modifier = Modifier.fillMaxSize(),
)
}
TokenDetailsTopBar(topAppBarUM = tokenDetailsUM.topAppBarUM)
if (tokenMarketBlockComponent != null) {
TokenDetailsMarketBlockOverlay(
component = tokenMarketBlockComponent,
onHeightChange = { marketBlockHeight = it },
)
}
expressState.bottomSheetSlot?.content(null)
}
TokenDetailsTopBarOverlay(topAppBarUM = tokenDetailsUM.topAppBarUM)
if (tokenMarketBlockComponent != null) {
TokenDetailsMarketBlockOverlay(
component = tokenMarketBlockComponent,
onHeightChange = { marketBlockHeight = it },
)
}
expressState.bottomSheetSlot?.content(null)
}
}
@Composable
private fun TokenDetailsTopBarOverlay(topAppBarUM: TokenDetailsTopAppBarUM) {
Box(
modifier = Modifier.background(TangemTheme.colors2.surface.level2),
) {
TokenDetailsTopBar(topAppBarUM = topAppBarUM)
}
}
@ -165,7 +143,7 @@ private fun BoxScope.TokenDetailsMarketBlockOverlay(
)
}
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
@Composable
private fun TokenDetailsBody(
tokenDetailsUM: TokenDetailsUM,
@ -189,7 +167,15 @@ private fun TokenDetailsBody(
.padding(start = TangemTheme.dimens2.x4, end = TangemTheme.dimens2.x4, top = TangemTheme.dimens2.x4)
LazyColumn(
modifier = modifier,
modifier = modifier
.testTag(TokenDetailsScreenTestTags.SCREEN_CONTAINER)
.hazeSourceTangem(state = LocalHazeState.current)
.topFade(
height = topContentPadding,
0f to rootBackground,
TOP_FADE_MID_STOP to rootBackground.copy(alpha = TOP_FADE_MID_ALPHA),
1f to Color.Transparent,
),
state = listState,
contentPadding = PaddingValues(top = topContentPadding, bottom = bottomContentPadding),
) {
@ -201,14 +187,6 @@ private fun TokenDetailsBody(
)
}
val balance = tokenDetailsUM.balanceBlockUM
if (balance is TokenDetailsBalanceBlockUM.Content && balance.isBalanceZero) {
item(key = "zero_balance_actions") {
ZeroBalanceActionsBlock(
state = tokenDetailsUM.zeroBalanceActionsUM,
modifier = itemModifier,
)
}
}
notifications(
notifications = tokenDetailsUM.notifications,
contentColor = rootBackground,
@ -216,14 +194,31 @@ private fun TokenDetailsBody(
)
tokenDetailsUM.earnBlockState?.let { earnBlock ->
item(key = "staking_block") {
val stakingTag = when {
earnBlock is EarnBlockUM.Content &&
earnBlock.type == EarnBlockUM.Type.Staking &&
earnBlock.trailingUM is EarnBlockUM.TrailingUM.Button ->
TokenDetailsScreenTestTags.STAKING_AVAILABLE_BLOCK
else -> TokenDetailsScreenTestTags.STAKING_BLOCK
}
EarnBlock(
state = earnBlock,
modifier = itemModifier,
modifier = itemModifier
.padding(vertical = TangemTheme.dimens2.x3)
.testTag(stakingTag),
)
}
}
item(key = "yield_supply_block") {
yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2))
yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x3))
}
if (balance is TokenDetailsBalanceBlockUM.Content && balance.isBalanceZero) {
item(key = "zero_balance_actions") {
ZeroBalanceActionsBlock(
state = tokenDetailsUM.zeroBalanceActionsUM,
modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2),
)
}
}
with(expressTransactionsComponent) {
expressTransactionsContent(
@ -231,6 +226,14 @@ private fun TokenDetailsBody(
modifier = expressTransactionModifier,
)
}
tokenDetailsUM.quickTopUpBlock?.let { quickTopUpBlock ->
item(key = "quick_top_up_block") {
QuickTopUpBlock(
state = quickTopUpBlock,
modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0),
)
}
}
with(txHistoryComponent) {
txHistoryContent(listState = listState, state = txHistoryState)
}

View file

@ -160,6 +160,15 @@ internal fun TokenDetailsScreenLegacy(
)
}
state.quickTopUpBlock?.let { quickTopUpBlock ->
item(key = "quick_top_up_block") {
QuickTopUpBlock(
state = quickTopUpBlock,
modifier = itemModifier,
)
}
}
with(txHistoryComponent) {
txHistoryContentLegacy(listState = listState, state = txHistoryComponentState)
}

View file

@ -1,8 +1,10 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.text.appendInlineContent
@ -11,11 +13,21 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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.platform.LocalDensity
import androidx.compose.ui.text.*
import androidx.compose.ui.platform.testTag
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.core.ui.test.TokenDetailsTopBarTestTags
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -30,6 +42,8 @@ import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.haze.ProvideHaze
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds.topbar.TangemTopBar
@ -44,19 +58,24 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.*
import com.tangem.core.ui.R as CoreUiR
@Composable
internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: Modifier = Modifier) {
val actionModifier = Modifier
.clip(CircleShape)
.hazeEffectTangem { blurRadius = ACTION_BLUR_RADIUS }
TangemTopBar(
modifier = modifier.statusBarsPadding(),
modifier = modifier
.statusBarsPadding()
.testTag(TokenDetailsTopBarTestTags.BACK_BUTTON),
startContent = {
TangemTopBarActionContent(
modifier = actionModifier,
actionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_arrow_back_28,
onClick = topAppBarUM.onBackClick,
ghostModeProgress = 1f,
),
)
},
@ -65,10 +84,10 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier:
var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) }
Box {
TangemTopBarActionContent(
modifier = actionModifier.testTag(TokenDetailsTopBarTestTags.MORE_BUTTON),
actionUM = TangemTopBarActionUM(
iconRes = CoreUiR.drawable.ic_more_default_24,
onClick = { isDropdownMenuShown = true },
ghostModeProgress = 1f,
),
)
TangemDropdownMenu(
@ -97,11 +116,13 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier:
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5),
) {
TokenDetailsTitle(titleState = topAppBarUM.titleState)
Box(modifier = Modifier.testTag(TokenDetailsScreenTestTags.TOKEN_TITLE)) {
TokenDetailsTitle(titleState = topAppBarUM.titleState)
}
Text(
text = topAppBarUM.subtitle.resolveAnnotatedReference(),
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.tertiary,
style = TangemTheme.typography2.captionSemibold12,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -138,11 +159,7 @@ private fun TokenDetailsTitle(titleState: TitleState) {
AdaptiveTokenWithSecondaryRow(
tokenName = titleState.tokenName,
secondaryName = titleState.walletName,
template = stringResourceSafe(
id = CoreUiR.string.token_details_toolbar_title_token_in_wallet,
titleState.tokenName,
titleState.walletName,
),
template = formattedTitleTemplate(CoreUiR.string.token_details_toolbar_title_token_in_wallet),
appearance = appearance,
icon = {
TangemDeviceIcon(
@ -153,21 +170,16 @@ private fun TokenDetailsTitle(titleState: TitleState) {
)
}
is TitleState.WithAccount -> {
val accountNameStr = titleState.accountName.resolveAnnotatedReference().toString()
AdaptiveTokenWithSecondaryRow(
tokenName = titleState.tokenName,
secondaryName = accountNameStr,
template = stringResourceSafe(
id = CoreUiR.string.token_details_toolbar_title_token_in_account,
titleState.tokenName,
accountNameStr,
),
secondaryName = titleState.accountName.resolveAnnotatedReference().toString(),
template = formattedTitleTemplate(CoreUiR.string.token_details_toolbar_title_token_in_account),
appearance = appearance,
icon = {
AccountIcon(
name = titleState.accountName,
icon = titleState.accountIconUM,
size = AccountIconSize.ExtraSmall,
size = AccountIconSize.RedesignExtraSmall,
)
},
)
@ -178,9 +190,12 @@ private fun TokenDetailsTitle(titleState: TitleState) {
/**
* Adaptive title for [TitleState.WithAccount] / [TitleState.WithWallet].
*
* Phrase template carries the [IMAGE_PLACEHOLDER] marker translator decides where
* the icon sits (e.g. "Tether in [⭐] Portfolio" or "Tether in My Wallet [⭐]").
* RTL is handled by BiDi inside the single [Text].
* Raw template carries [TOKEN_MARKER] / [SECONDARY_MARKER] for the names and
* [IMAGE_PLACEHOLDER] for the icon translator decides where each sits
* (e.g. "Tether in [⭐] Portfolio" or "Tether in My Wallet [⭐]"). Anything outside
* the markers (connecting words, punctuation) is painted as tertiary text no
* locale-specific substring matching required. RTL is handled by BiDi inside the
* single [Text].
*
* Width-driven cascade:
* 12. Full phrase as single [Text] with inline icon; [TextOverflow.Ellipsis]
@ -201,7 +216,7 @@ private fun AdaptiveTokenWithSecondaryRow(
appearance: TitleAppearance,
icon: @Composable () -> Unit,
) {
val (beforeIcon, afterIcon) = remember(template) { splitTemplate(template) }
val segments = remember(template) { parseTemplate(template) }
val inlineContent = rememberIconInlineContent(appearance.iconSize, icon)
BoxWithConstraints(
@ -209,16 +224,16 @@ private fun AdaptiveTokenWithSecondaryRow(
contentAlignment = Alignment.Center,
) {
val isFullTextShown = rememberShouldShowFullText(
beforeIcon = beforeIcon,
afterIcon = afterIcon,
secondaryName = secondaryName,
segments = segments,
tokenName = tokenName,
appearance = appearance,
maxWidthPx = constraints.maxWidth,
)
if (isFullTextShown) {
FullPhraseTitle(
beforeIcon = beforeIcon,
afterIcon = afterIcon,
segments = segments,
tokenName = tokenName,
secondaryName = secondaryName,
style = appearance.style,
inlineContent = inlineContent,
)
@ -232,17 +247,25 @@ private fun AdaptiveTokenWithSecondaryRow(
}
}
private fun splitTemplate(template: String): Pair<String, String> {
val parts = template.split(IMAGE_PLACEHOLDER, limit = 2)
return if (parts.size == 2) parts[0] to parts[1] else template to ""
}
/**
* Returns the title template with its `%1$s` / `%2$s` placeholders replaced by [TOKEN_MARKER] /
* [SECONDARY_MARKER] sentinels (and `%%image%%` collapsed to [IMAGE_PLACEHOLDER]).
*
* Feeding the markers in as format args lets [String.format] resolve all escaping for us, so
* [parseTemplate] sees a clean string and the real names stay un-substituted until render time.
*/
@Composable
private fun formattedTitleTemplate(@StringRes id: Int): String = stringResourceSafe(id, TOKEN_MARKER, SECONDARY_MARKER)
@Composable
private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit): Map<String, InlineTextContent> {
private fun rememberIconInlineContent(
iconSize: Dp,
icon: @Composable () -> Unit,
): ImmutableMap<String, InlineTextContent> {
val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() }
val currentIcon by rememberUpdatedState(icon)
return remember(iconSizeSp) {
mapOf(
persistentMapOf(
ICON_INLINE_ID to InlineTextContent(
placeholder = Placeholder(
width = iconSizeSp,
@ -261,23 +284,29 @@ private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit
@Composable
private fun rememberShouldShowFullText(
beforeIcon: String,
afterIcon: String,
secondaryName: String,
segments: ImmutableList<TitleSegment>,
tokenName: String,
appearance: TitleAppearance,
maxWidthPx: Int,
): Boolean {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(beforeIcon, afterIcon, secondaryName, maxWidthPx, appearance, density) {
return remember(segments, tokenName, maxWidthPx, appearance, density) {
if (maxWidthPx <= 0) return@remember true
val fullTextWidthPx = measurer
.measure(text = beforeIcon + afterIcon, style = appearance.style, softWrap = false)
.size.width
val secondaryWidthPx = measurer
.measure(text = secondaryName, style = appearance.style, softWrap = false)
.size.width
val staticWidthPx = (fullTextWidthPx - secondaryWidthPx).coerceAtLeast(0)
val staticText = buildString {
for (segment in segments) {
when (segment) {
TitleSegment.Token -> append(tokenName)
is TitleSegment.Plain -> append(segment.text)
TitleSegment.Secondary, TitleSegment.Image -> Unit
}
}
}
val staticWidthPx = if (staticText.isEmpty()) {
0
} else {
measurer.measure(text = staticText, style = appearance.style, softWrap = false).size.width
}
val iconReservePx = with(density) {
(appearance.iconSize + appearance.spacing * 2).toPx()
}.toInt()
@ -288,16 +317,25 @@ private fun rememberShouldShowFullText(
@Composable
private fun FullPhraseTitle(
beforeIcon: String,
afterIcon: String,
segments: ImmutableList<TitleSegment>,
tokenName: String,
secondaryName: String,
style: TextStyle,
inlineContent: Map<String, InlineTextContent>,
inlineContent: ImmutableMap<String, InlineTextContent>,
) {
val fullText = remember(beforeIcon, afterIcon) {
val tertiaryColor = TangemTheme.colors2.text.neutral.tertiary
val fullText = remember(segments, tokenName, secondaryName, tertiaryColor) {
buildAnnotatedString {
append(beforeIcon)
appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER)
append(afterIcon)
for (segment in segments) {
when (segment) {
TitleSegment.Token -> append(tokenName)
TitleSegment.Secondary -> append(secondaryName)
TitleSegment.Image -> appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER)
is TitleSegment.Plain -> withStyle(SpanStyle(color = tertiaryColor)) {
append(segment.text)
}
}
}
}
}
Text(
@ -311,6 +349,47 @@ private fun FullPhraseTitle(
)
}
private sealed interface TitleSegment {
data object Token : TitleSegment
data object Secondary : TitleSegment
data object Image : TitleSegment
data class Plain(val text: String) : TitleSegment
}
/**
* Splits the [String.format]ed template (see [formattedTitleTemplate]) into ordered segments.
*
* Markers appear in their resolved form: [TOKEN_MARKER] / [SECONDARY_MARKER] for the names and
* [IMAGE_PLACEHOLDER] for the icon (escaping was already collapsed by [String.format]). Anything
* else is plain connecting text painted as tertiary.
*/
private fun parseTemplate(template: String): ImmutableList<TitleSegment> {
val segments = mutableListOf<TitleSegment>()
val plain = StringBuilder()
fun flushPlain() {
if (plain.isNotEmpty()) {
segments += TitleSegment.Plain(plain.toString())
plain.clear()
}
}
var i = 0
while (i < template.length) {
val marker = MARKERS.firstOrNull { (text, _) -> template.startsWith(text, i) }
if (marker != null) {
flushPlain()
segments += marker.second
i += marker.first.length
} else {
plain.append(template[i])
i++
}
}
flushPlain()
return segments.toImmutableList()
}
@Composable
private fun FallbackTokenWithIconTitle(tokenName: String, appearance: TitleAppearance, icon: @Composable () -> Unit) {
Row(
@ -342,7 +421,17 @@ private data class TitleAppearance(
private const val ICON_INLINE_ID = "account_icon"
private const val IMAGE_PLACEHOLDER = "%image%"
private const val TOKEN_MARKER = "%1\$s"
private const val SECONDARY_MARKER = "%2\$s"
// Order matters: longer/overlapping markers must be tried first by [parseTemplate].
private val MARKERS: List<Pair<String, TitleSegment>> = listOf(
IMAGE_PLACEHOLDER to TitleSegment.Image,
TOKEN_MARKER to TitleSegment.Token,
SECONDARY_MARKER to TitleSegment.Secondary,
)
private val ACTION_BLUR_RADIUS = 8.dp
private val MIN_TITLE_FONT_SIZE = 12.sp
private val MAX_TITLE_FONT_SIZE = 16.sp
private val MIN_SECONDARY_NAME_WIDTH = 48.dp
@ -355,20 +444,22 @@ private fun TokenDetailsTopBar_Preview(
@PreviewParameter(TokenDetailsTopBarPreviewProvider::class) titleState: TitleState,
) {
TangemThemePreviewRedesign {
TokenDetailsTopBar(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = titleState,
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(
TangemDropdownMenuItem(
title = stringReference("Hide Token"),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = {},
ProvideHaze {
TokenDetailsTopBar(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = titleState,
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(
TangemDropdownMenuItem(
title = stringReference("Hide Token"),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = {},
),
),
),
),
)
)
}
}
}

View file

@ -1,59 +0,0 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import kotlinx.coroutines.flow.StateFlow
import com.tangem.core.ui.R as CoreR
internal class AddFundsBottomSheetComponent(
private val stateFlow: StateFlow<AddFundsUM>,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() {
onDismiss()
}
@Composable
override fun BottomSheet() {
val state by stateFlow.collectAsStateWithLifecycle()
val config = remember(state) {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = state,
)
}
TangemModalBottomSheet<AddFundsUM>(
config = config,
containerColor = TangemTheme.colors2.surface.level2,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(CoreR.string.common_get_token),
endIconRes = CoreR.drawable.ic_close_24,
onEndClick = ::dismiss,
)
},
content = { contentState ->
AddFundsBottomSheetContent(
state = contentState,
onCloseClick = ::dismiss,
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
)
},
)
}
}

View file

@ -1,167 +0,0 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
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.common.ui.tokenaction.TokenActionRow
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import dev.chrisbanes.haze.rememberHazeState
import com.tangem.core.ui.R as CoreR
@Composable
internal fun AddFundsBottomSheetContent(state: AddFundsUM, onCloseClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
BuyActionRow(state = state)
SwapActionRow(state = state)
ReceiveActionRow(state = state)
SpacerH(TangemTheme.dimens2.x2)
CompositionLocalProvider(LocalHazeState provides rememberHazeState()) {
SecondaryTangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = onCloseClick,
text = resourceReference(CoreR.string.common_close),
size = TangemButtonSize.X12,
shape = TangemButtonShape.Rounded,
)
}
SpacerH(TangemTheme.dimens2.x4)
}
}
@Composable
private fun BuyActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.buy
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_credit_card_20,
title = resourceReference(CoreR.string.common_buy),
description = resourceReference(CoreR.string.quick_action_buy_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun SwapActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.swap
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_exchange_mini_24,
title = resourceReference(CoreR.string.common_swap),
description = resourceReference(CoreR.string.quick_action_swap_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun ReceiveActionRow(state: AddFundsUM) {
val row = (state as? AddFundsUM.Content)?.receive
if (state is AddFundsUM.Content && row == null) return
ActionRow(
iconRes = CoreR.drawable.ic_qrcode_new_24,
title = resourceReference(CoreR.string.common_receive),
description = resourceReference(CoreR.string.quick_action_receive_description),
row = row,
isLoading = state is AddFundsUM.Loading,
)
}
@Composable
private fun ActionRow(
iconRes: Int,
title: TextReference,
description: TextReference,
row: AddFundsUM.Row?,
isLoading: Boolean,
) {
if (isLoading || row?.isLoading == true) {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
tailContent = { TailLoader() },
)
} else {
TokenActionRow(
iconRes = iconRes,
title = title,
description = description,
onClick = row?.onClick,
onLongClick = row?.onLongClick,
isEnabled = row?.isEnabled == true,
)
}
}
@Composable
private fun TailLoader() {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = TangemTheme.colors2.graphic.neutral.tertiary,
strokeWidth = 2.dp,
)
}
// region Preview
@Preview(widthDp = 360, showBackground = true)
@Composable
private fun Preview(@PreviewParameter(AddFundsPreviewProvider::class) state: AddFundsUM) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
AddFundsBottomSheetContent(
state = state,
onCloseClick = {},
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
private class AddFundsPreviewProvider : PreviewParameterProvider<AddFundsUM> {
override val values: Sequence<AddFundsUM> = sequenceOf(
AddFundsUM.Loading,
AddFundsUM.Content(
buy = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}),
swap = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}),
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
AddFundsUM.Content(
buy = AddFundsUM.Row(isLoading = false, isEnabled = false, onClick = {}),
swap = AddFundsUM.Row(isLoading = false, isEnabled = false, onClick = {}),
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
AddFundsUM.Content(
buy = null,
swap = null,
receive = AddFundsUM.Row(isLoading = false, isEnabled = true, onClick = {}, onLongClick = {}),
),
)
}
// endregion

View file

@ -4,16 +4,10 @@ import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -102,8 +96,7 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd
targetState = state.tokenBalanceTypeUM.type,
label = "Token balance type",
) { currentType ->
val tokenBalanceTypeUM = state.tokenBalanceTypeUM
when (tokenBalanceTypeUM) {
when (val tokenBalanceTypeUM = state.tokenBalanceTypeUM) {
is TokenBalanceTypeUM.Multiple -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
@ -111,20 +104,20 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd
) {
Text(
text = currentType.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.primary,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_sort_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.secondary,
modifier = Modifier.size(TangemTheme.dimens2.x4),
modifier = Modifier.size(TangemTheme.dimens2.x5),
)
}
TokenBalanceTypeUM.Single -> Text(
text = currentType.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.primary,
)
}
}

View file

@ -1,11 +1,7 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -26,7 +22,7 @@ internal fun ZeroBalanceActionsBlock(state: ZeroBalanceActionsUM, modifier: Modi
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = TangemTheme.dimens2.x10),
.padding(bottom = TangemTheme.dimens2.x6),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
ActionRow(

View file

@ -0,0 +1,347 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class QuickTopUpBlockFactoryTest {
private val featureToggles: TokenDetailsFeatureToggles = mockk {
every { isQuickTopUpEnabled } returns true
}
private val factory = QuickTopUpBlockFactory(featureToggles)
private val zeroBalanceStatus: CryptoCurrencyStatus = mockk {
every { value } returns mockk<CryptoCurrencyStatus.Loaded> {
every { amount } returns BigDecimal.ZERO
}
}
private val nonZeroBalanceStatus: CryptoCurrencyStatus = mockk {
every { value } returns mockk<CryptoCurrencyStatus.Loaded> {
every { amount } returns BigDecimal.TEN
}
}
private val usdCurrency = OnrampCurrency(
name = "US Dollar",
code = "USD",
image = null,
precision = 2,
unit = "$",
)
private val eurCurrency = OnrampCurrency(
name = "Euro",
code = "EUR",
image = null,
precision = 2,
unit = "",
)
private val gbpCurrency = OnrampCurrency(
name = "British Pound",
code = "GBP",
image = null,
precision = 2,
unit = "£",
)
private val countryMock: OnrampCountry = mockk(relaxed = true)
private val availableUsd: OnrampAvailability = OnrampAvailability.Available(
country = countryMock,
currency = usdCurrency,
)
private val notSupported: OnrampAvailability = OnrampAvailability.NotSupported(country = countryMock)
private val emptyHistory = TxHistoryStateError.EmptyTxHistories.left()
private val histWithItems = 5.right()
private val histRightZero = 0.right()
@Test
fun `returns null when feature toggle is disabled`() {
val disabledToggles: TokenDetailsFeatureToggles = mockk {
every { isQuickTopUpEnabled } returns false
}
val disabledFactory = QuickTopUpBlockFactory(disabledToggles)
val result = disabledFactory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when balance is non-zero`() {
val result = factory.build(
currencyStatus = nonZeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when history has transactions`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = histWithItems,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when onramp is not available`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = notSupported.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when currency is not USD or EUR`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampAvailability.Available(
country = countryMock,
currency = gbpCurrency,
).right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns block with USD presets when all conditions met`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("$50"),
stringReference("$200"),
stringReference("$700"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
assertThat(amounts.last().isOther).isTrue()
assertThat(amounts.take(3).all { !it.isOther }).isTrue()
}
@Test
fun `returns block with EUR presets`() {
val availableEur = OnrampAvailability.Available(
country = countryMock,
currency = eurCurrency,
)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableEur.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("€50"),
stringReference("€200"),
stringReference("€650"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
assertThat(amounts.last().isOther).isTrue()
}
@Test
fun `returns block when history count is right zero (boundary case)`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = histRightZero,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
}
@Test
fun `returns block when ConfirmResidency and country supports onramp with USD`() {
val usdCountry = OnrampCountry(
id = "us",
name = "United States",
code = "US",
image = "",
alpha3 = "USA",
continent = "America",
defaultCurrency = usdCurrency,
onrampAvailable = true,
)
val confirmResidency = OnrampAvailability.ConfirmResidency(country = usdCountry)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = confirmResidency.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("$50"),
stringReference("$200"),
stringReference("$700"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
}
@Test
fun `returns null when onramp availability is error`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampError.DataError(code = "error", description = null).left(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when balance is loading (amount is null)`() {
val loadingStatus: CryptoCurrencyStatus = mockk {
every { value } returns CryptoCurrencyStatus.Loading
}
val result = factory.build(
currencyStatus = loadingStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when tx history is not implemented`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = TxHistoryStateError.TxHistoryNotImplemented.left(),
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when tx history fetch fails with data error`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = TxHistoryStateError.DataError(RuntimeException("network error")).left(),
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when ConfirmResidency with non-USD or EUR default currency`() {
val gbpCountry = OnrampCountry(
id = "gb",
name = "United Kingdom",
code = "GB",
image = "",
alpha3 = "GBR",
continent = "Europe",
defaultCurrency = gbpCurrency,
onrampAvailable = true,
)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampAvailability.ConfirmResidency(country = gbpCountry).right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when ConfirmResidency but country does not support onramp`() {
val restrictedCountry = OnrampCountry(
id = "kp",
name = "North Korea",
code = "KP",
image = "",
alpha3 = "PRK",
continent = "Asia",
defaultCurrency = usdCurrency,
onrampAvailable = false,
)
val confirmResidency = OnrampAvailability.ConfirmResidency(country = restrictedCountry)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = confirmResidency.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
}