Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-10 13:24:16 +05:00
parent 76c129f205
commit a13a54accd
32 changed files with 665 additions and 35 deletions

View file

@ -122,6 +122,10 @@ object PreferencesKeys {
val YIELD_SUPPLY_WARNINGS_STATES_KEY by lazy { stringPreferencesKey(name = "yieldSupplyWarningsStates") }
val YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY by lazy {
booleanPreferencesKey(name = "yieldSupplyShouldShowMainPromo")
}
val ACCESS_CODE_SKIPPED_STATES_KEY by lazy { stringPreferencesKey(name = "accessCodeSkippedStates") }
// region Notifications

View file

@ -590,6 +590,7 @@
<string name="hw_backup_need_finish_first">まずバックアップを完了する</string>
<string name="hw_backup_need_title">まずバックアップを完了する</string>
<string name="hw_backup_section_other_title">その他の方法</string>
<string name="hw_backup_seed_description">リカバリーフレーズは、ご自身で安全な場所に保管し、資金を守るために他人には絶対に共有しないでください。</string>
<string name="hw_backup_seed_title">リカバリーフレーズ</string>
<string name="hw_backup_to_secure_description">アクセスコードを使用してウォレットを保護するには、まずバックアップを完了してください。</string>
<string name="hw_backup_to_upgrade_description">ウォレットをハードウェアにアップグレードするには、まずバックアップしてください。</string>
@ -1432,6 +1433,8 @@
<string name="tangempay_failed_to_issue_card">カードの発行に失敗しました</string>
<string name="tangempay_failed_to_issue_card_retry_description">技術的なエラーが発生しました。下のボタンをクリックして、もう一度お試しください。</string>
<string name="tangempay_failed_to_issue_card_support_description">技術的なエラーが発生しました。サポートへお問い合わせください。</string>
<string name="tangempay_get_banner_description">暗号資産を日常の支払いに使おう。\n\nこれまでにないタイプの決済カード。</string>
<string name="tangempay_get_tangem_pay">Tangem Payを入手</string>
<string name="tangempay_issue_card_notification_description">通常は最大で15分ほどかかります</string>
<string name="tangempay_issue_card_notification_title">Tangemカードのセットアップ</string>
<string name="tangempay_issuing_your_card">カードを発行しています</string>
@ -1454,6 +1457,7 @@
<string name="tangempay_service_unavailable_description">技術的な問題を修正しています。後でもう一度お試しください。</string>
<string name="tangempay_service_unavailable_title">サービスは一時的に利用できません</string>
<string name="tangempay_service_unreachable_try_later">現在サービスに接続できません。後ほどもう一度お試しください。</string>
<string name="tangempay_tangem_visa_card">Tangem Visaカード</string>
<string name="tangempay_temporarily_unavailable">Tangem Payは一時的に利用できません</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">カードまたはリングを使用して、支払いアカウントへのアクセスを復元してください。</string>
@ -1500,6 +1504,7 @@
<string name="transaction_history_multiple_addresses">複数のアドレス</string>
<string name="transaction_history_not_supported_description">現在、このブロックチェーンでは取引履歴はサポートされていません。しかしご心配なく!弊社で対応中です。その間、エクスプローラーで確認することができます。</string>
<string name="transaction_history_operation">オペレーション</string>
<string name="transaction_history_transaction_for_address">対象:%s</string>
<string name="transaction_history_transaction_from_address">送金元: %s</string>
<string name="transaction_history_transaction_to_address">送金先: %s</string>
<string name="transaction_history_transaction_validator">バリデーター: %s</string>

View file

@ -527,7 +527,7 @@
<string name="express_status_buying">Buying %s</string>
<string name="express_status_buying_active">Buying %s...</string>
<string name="express_status_hide_button_text">Hide this transaction</string>
<string name="express_status_hide_dialog_text">Once hidden, the transaction status cannot be viewed again. You can simply swipe to dismiss instead.</string>
<string name="express_status_hide_dialog_text">If you hide this transaction, it wont appear in the status screen anymore. If you just want to close the status screen and return later, simply swipe it away instead.</string>
<string name="express_status_hide_dialog_title">Hide Transaction Status?</string>
<string name="express_swap_not_supported_text">This token is not supported. Please choose a different token to swap.</string>
<string name="express_swap_not_supported_title">%s is not supported</string>
@ -697,11 +697,13 @@
<string name="markets_apy_placeholder">APY %s</string>
<string name="markets_common_my_portfolio">My portfolio</string>
<string name="markets_common_title">Market</string>
<string name="markets_earn_common_title">Earn with Tangem</string>
<string name="markets_generate_addresses_notification">To generate addresses for selected networks, you must scan your Tangem Wallet card or ring</string>
<string name="markets_hint">To add tokens pull this up or tap the search bar</string>
<string name="markets_insights_info_description_message">This sections data is sourced from the following networks: %s</string>
<string name="markets_loading_error_title">Unable to load the data…</string>
<string name="markets_loading_no_data_title">No data</string>
<string name="markets_pulse_common_title">Market Pulse</string>
<string name="markets_quick_actions">Quick actions</string>
<string name="markets_search_header_title">Search through the market</string>
<string name="markets_search_result_title">Result</string>
@ -1985,7 +1987,7 @@
<string name="yield_module_fee_policy_tangem_service_fee_title">Tangem also takes a 15% service fee on yield generated.</string>
<string name="yield_module_high_fee_error">Your funds will be automatically supplied to Aave once network fees are lower or your balance meets the minimum required amount.</string>
<string name="yield_module_historical_returns">Historical returns</string>
<string name="yield_module_main_screen_promo_banner_message">"Enable %1$s%% APY on your balance"</string>
<string name="yield_module_main_screen_promo_banner_message">Enable %1$s%% APY on your balance</string>
<string name="yield_module_main_view_approve_notification_description">Approval for your token in Yield Mode has been revoked. Open the token to grant permission again.</string>
<string name="yield_module_main_view_approve_notification_title">Token approval needed</string>
<string name="yield_module_network_fee_unreachable_notification_description">Check your network connection</string>

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.components.token
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -18,6 +19,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
@ -28,6 +30,7 @@ import com.tangem.core.ui.components.token.internal.*
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.components.token.state.TokenItemState.Subtitle2State
import com.tangem.core.ui.components.token.state.TokenItemState.PromoBannerState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.extensions.stringReference
@ -44,7 +47,7 @@ private const val TITLE_MIN_WIDTH_COEFFICIENT = 0.3
private const val PRICE_MIN_WIDTH_COEFFICIENT = 0.32
private enum class LayoutId {
ICON, TITLE, FIAT_AMOUNT, CRYPTO_AMOUNT, CRYPTO_PRICE, NON_FIAT_CONTENT
ICON, TITLE, FIAT_AMOUNT, CRYPTO_AMOUNT, CRYPTO_PRICE, NON_FIAT_CONTENT, PROMO_BANNER
}
/**
@ -109,6 +112,14 @@ fun TokenItem(
.testTag(TokenElementsTestTags.TOKEN_ICON),
)
YieldSupplyPromoBanner(
state = state.promoBannerState,
modifier = Modifier
.layoutId(layoutId = LayoutId.PROMO_BANNER)
.testTag(TokenElementsTestTags.TOKEN_YIELD_PROMO_BANNER)
.fillMaxWidth(),
)
TokenTitle(
state = state.titleState,
modifier = Modifier
@ -220,6 +231,13 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
val nonFiatContent = measurables.measure(layoutId = LayoutId.NON_FIAT_CONTENT, constraints = constraints)
val promoBanner = when (state.promoBannerState) {
is PromoBannerState.Content -> measurables.measure(
layoutId = LayoutId.PROMO_BANNER,
constraints = constraints,
)
else -> null
}
var firstRowRemainingFreeSpace: Int? = null
var secondRowRemainingFreeSpace: Int? = null
@ -283,10 +301,18 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
)
}
val promoBannerHeight = promoBanner?.height ?: 0
val promoOffset = if (promoBannerHeight > 0) {
promoBannerHeight - 8.dp.roundToPx()
} else {
0
}
val layoutHeight = calculateLayoutHeight(
state = state,
minLayoutHeight = with(density) { dimens.size68.roundToPx() },
layoutPadding = verticalPadding,
promoOffset = promoOffset,
title = title,
fiatAmount = fiatAmount,
cryptoAmount = cryptoAmount,
@ -294,16 +320,22 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
)
layout(width = constraints.maxWidth, height = layoutHeight) {
icon.placeRelative(x = 0, y = (layoutHeight - icon.height).div(other = 2))
promoBanner?.placeRelative(x = 0, y = 0)
icon.placeRelative(
x = 0,
y = promoOffset + (layoutHeight - promoOffset - icon.height)
.div(other = 2),
)
title.placeRelative(
x = icon.width,
y = when (state) {
y = promoOffset + when (state) {
is TokenItemState.NoAddress,
is TokenItemState.Unreachable,
-> {
if (state.subtitleState == null) {
(layoutHeight - title.height).div(other = 2)
(layoutHeight - promoOffset - title.height).div(other = 2)
} else {
verticalPadding
}
@ -314,8 +346,8 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
fiatAmount?.placeRelative(
x = layoutWidth - fiatAmount.width,
y = when (state.subtitle2State) {
null -> (layoutHeight - fiatAmount.height).div(other = 2)
y = promoOffset + when (state.subtitle2State) {
null -> (layoutHeight - promoOffset - fiatAmount.height).div(other = 2)
else -> verticalPadding
},
)
@ -335,7 +367,7 @@ private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier
nonFiatContent.placeRelative(
x = layoutWidth - nonFiatContent.width,
y = (layoutHeight - nonFiatContent.height).div(other = 2),
y = promoOffset + (layoutHeight - promoOffset - nonFiatContent.height).div(other = 2),
)
}
}
@ -443,6 +475,7 @@ private fun calculateLayoutHeight(
state: TokenItemState,
minLayoutHeight: Int,
layoutPadding: Int,
promoOffset: Int,
title: Placeable,
fiatAmount: Placeable?,
cryptoAmount: Placeable?,
@ -468,7 +501,7 @@ private fun calculateLayoutHeight(
}
}
return max(firstColumnHeight, secondColumnHeight).coerceAtLeast(minLayoutHeight)
return (promoOffset + max(firstColumnHeight, secondColumnHeight)).coerceAtLeast(promoOffset + minLayoutHeight)
}
@Preview(widthDp = 360, showBackground = true)
@ -587,6 +620,11 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider<TokenI
),
onItemClick = {},
onItemLongClick = {},
promoBannerState = PromoBannerState.Content(
title = TextReference.Str(value = "Trusted"),
onPromoBannerClick = {},
onCloseClick = {},
),
),
TokenItemState.Loading(
id = "Loading#1",

View file

@ -0,0 +1,110 @@
package com.tangem.core.ui.components.token.internal
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import android.content.res.Configuration
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ripple
import androidx.compose.runtime.remember
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.token.state.TokenItemState.PromoBannerState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@Composable
internal fun YieldSupplyPromoBanner(state: PromoBannerState, modifier: Modifier = Modifier) {
when (state) {
is PromoBannerState.Content -> YieldSupplyPromoBanner(state = state, modifier = modifier)
is PromoBannerState.Empty -> Unit
}
}
@Composable
internal fun YieldSupplyPromoBanner(state: PromoBannerState.Content, modifier: Modifier = Modifier) {
val bgColor = TangemTheme.colors.control.unchecked
Column(modifier = modifier) {
Row(
modifier = Modifier
.background(color = bgColor, shape = TangemTheme.shapes.roundedCornersXMedium)
.padding(horizontal = 12.dp, vertical = 8.dp)
.clickable(onClick = state.onPromoBannerClick)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_analytics_up_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
modifier = Modifier
.padding(end = TangemTheme.dimens.spacing8)
.size(TangemTheme.dimens.size16),
)
Text(
text = state.title.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.weight(1f)
.padding(end = TangemTheme.dimens.spacing8),
)
Icon(
painter = painterResource(id = R.drawable.ic_close_24),
contentDescription = null,
tint = TangemTheme.colors.text.secondary,
modifier = Modifier
.size(TangemTheme.dimens.size16)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = { state.onCloseClick() },
),
)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_rectangle_bottom),
contentDescription = null,
tint = bgColor,
modifier = Modifier
.size(width = 12.dp, height = 8.dp),
)
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_YieldSupplyPromoBanner() {
TangemThemePreview {
YieldSupplyPromoBanner(
state = PromoBannerState.Content(
title = TextReference.Str(value = "Earn up to 5% APY"),
onPromoBannerClick = {},
onCloseClick = {},
),
modifier = Modifier.fillMaxWidth(),
)
}
}

View file

@ -34,6 +34,8 @@ sealed class TokenItemState {
*/
abstract val subtitle2State: Subtitle2State?
abstract val promoBannerState: PromoBannerState
/** Callback which will be called when an item is clicked */
abstract val onItemClick: ((TokenItemState) -> Unit)?
@ -59,6 +61,7 @@ sealed class TokenItemState {
) : TokenItemState() {
override val fiatAmountState: FiatAmountState = FiatAmountState.Loading
override val subtitle2State: Subtitle2State = Subtitle2State.Loading
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
@ -75,6 +78,7 @@ sealed class TokenItemState {
override val subtitleState: SubtitleState = SubtitleState.Locked
override val fiatAmountState: FiatAmountState = FiatAmountState.Locked
override val subtitle2State: Subtitle2State = Subtitle2State.Locked
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
@ -99,6 +103,7 @@ sealed class TokenItemState {
override val subtitleState: SubtitleState,
override val fiatAmountState: FiatAmountState?,
override val subtitle2State: Subtitle2State?,
override val promoBannerState: PromoBannerState = PromoBannerState.Empty,
override val onItemClick: ((TokenItemState) -> Unit)?,
override val onItemLongClick: ((TokenItemState) -> Unit)?,
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null,
@ -120,6 +125,7 @@ sealed class TokenItemState {
) : TokenItemState() {
override val subtitleState: SubtitleState? = null
override val fiatAmountState: FiatAmountState? = null
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onItemLongClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
@ -146,6 +152,7 @@ sealed class TokenItemState {
) : TokenItemState() {
override val fiatAmountState: FiatAmountState? = null
override val subtitle2State: Subtitle2State? = null
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
}
/**
@ -168,6 +175,7 @@ sealed class TokenItemState {
override val subtitle2State: Subtitle2State? = null
override val onItemClick: ((TokenItemState) -> Unit)? = null
override val onApyLabelClick: ((TokenItemState) -> Unit)? = null
override val promoBannerState: PromoBannerState = PromoBannerState.Empty
}
@Immutable
@ -254,4 +262,15 @@ sealed class TokenItemState {
data object Locked : Subtitle2State()
}
@Immutable
sealed class PromoBannerState {
data class Content(
val title: TextReference,
val onPromoBannerClick: () -> Unit,
val onCloseClick: () -> Unit,
) : PromoBannerState()
data object Empty : PromoBannerState()
}
}

View file

@ -7,4 +7,5 @@ object TokenElementsTestTags {
const val TOKEN_FIAT_AMOUNT = "TOKEN_FIAT_AMOUNT"
const val TOKEN_CRYPTO_AMOUNT = "TOKEN_CRYPTO_AMOUNT"
const val TOKEN_NON_FIAT_BLOCK = "TOKEN_NON_FIAT_BLOCK"
const val TOKEN_YIELD_PROMO_BANNER = "TOKEN_YIELD_PROMO_BANNER"
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp"
android:height="8dp"
android:viewportWidth="12"
android:viewportHeight="8">
<path
android:pathData="M0.198,1.178C0.034,0.976 -0.028,0.695 0.012,0.433C0.058,0.135 0.373,0 0.675,0H6.183H11.325C11.627,0 11.943,0.135 11.988,0.433C12.028,0.695 11.966,0.976 11.802,1.178L6.477,7.756C6.214,8.081 5.786,8.081 5.523,7.756L0.198,1.178Z"
android:fillColor="#EBEBEB"/>
</vector>

View file

@ -69,15 +69,16 @@ fun CoroutineScope.launchOnCancellation(block: suspend () -> Unit) {
}
@Suppress("LongParameterList", "MagicNumber")
inline fun <T1, T2, T3, T4, T5, T6, R> combine6(
inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine7(
flow1: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(flow1, flow2, flow3, flow4, flow5, flow6) { arr ->
flow7: Flow<T7>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R,
): Flow<R> = combine(flow1, flow2, flow3, flow4, flow5, flow6, flow7) { arr ->
@Suppress("UNCHECKED_CAST")
transform(
arr[0] as T1,
@ -86,5 +87,6 @@ inline fun <T1, T2, T3, T4, T5, T6, R> combine6(
arr[3] as T4,
arr[4] as T5,
arr[5] as T6,
arr[6] as T7,
)
}