Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 20:15:16 +03:00
commit 3428284898
821 changed files with 37777 additions and 8160 deletions

View file

@ -32,7 +32,7 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
enum class AccountIconSize {
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge, Contact
}
/**
@ -132,6 +132,8 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1
AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28
AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11
AccountIconSize.RedesignLarge -> TangemTheme.typography3.heading.medium
AccountIconSize.Contact -> TangemTheme.typography3.body.medium
}
val textSize by animateFloatAsState(
@ -166,6 +168,8 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) {
AccountIconSize.ExtraSmall -> 8.dp
AccountIconSize.RedesignedDefault -> 20.dp
AccountIconSize.RedesignExtraSmall -> 8.dp
AccountIconSize.RedesignLarge -> 32.dp
AccountIconSize.Contact -> 20.dp
}
fun AccountIconSize.toBoxSize(): Dp = when (this) {
@ -176,6 +180,8 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) {
AccountIconSize.ExtraSmall -> 14.dp
AccountIconSize.RedesignedDefault -> 40.dp
AccountIconSize.RedesignExtraSmall -> 16.dp
AccountIconSize.RedesignLarge -> 80.dp
AccountIconSize.Contact -> 40.dp
}
private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
@ -186,6 +192,8 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
AccountIconSize.ExtraSmall -> 4.dp
AccountIconSize.RedesignedDefault -> 12.dp
AccountIconSize.RedesignExtraSmall -> 6.dp
AccountIconSize.RedesignLarge -> 80.dp
AccountIconSize.Contact -> 100.dp
}
@Preview(showBackground = true)
@ -228,7 +236,9 @@ private fun Sample() {
AccountIconSize.Small -> AccountIconSize.ExtraSmall
AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault
AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall
AccountIconSize.RedesignExtraSmall -> AccountIconSize.Default
AccountIconSize.RedesignExtraSmall -> AccountIconSize.RedesignLarge
AccountIconSize.RedesignLarge -> AccountIconSize.Contact
AccountIconSize.Contact -> AccountIconSize.Default
}
}) { Text("Change") }

View file

@ -51,7 +51,10 @@ inline fun <reified T : StoryConfig> StoriesContainer(
) {
var watchedCounter by remember { mutableIntStateOf(1) }
var isPressed by remember { mutableStateOf(value = false) }
val storyState by remember(config) {
// Key on content (stories + repeatability), not the whole config object: an unrelated change
// to the config instance (e.g. a caller folding a changing flag into it) must not recreate the
// state machine and rewind stories to the first page.
val storyState by remember(config.stories, config.isRestartable) {
mutableStateOf(
StoriesStepStateMachine(
stories = config.stories,
@ -59,7 +62,9 @@ inline fun <reified T : StoryConfig> StoriesContainer(
),
)
}
BackHandler(onBack = { config.onClose(watchedCounter) })
if (config.isCloseButtonVisible) {
BackHandler(onBack = { config.onClose(watchedCounter) })
}
val isPaused = isPressed || isPauseStories
@ -90,22 +95,24 @@ inline fun <reified T : StoryConfig> StoriesContainer(
paused = isPaused,
onStepFinish = onNextClick,
)
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(R.drawable.ic_close_24),
),
tint = TangemTheme.colors.icon.constant,
contentDescription = null,
modifier = Modifier
.align(Alignment.End)
.padding(top = 14.dp, end = 16.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current,
onClick = { config.onClose(watchedCounter) },
)
.testTag(SwapStoriesScreenTestTags.CLOSE_BUTTON),
)
if (config.isCloseButtonVisible) {
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(R.drawable.ic_close_24),
),
tint = TangemTheme.colors.icon.constant,
contentDescription = null,
modifier = Modifier
.align(Alignment.End)
.padding(top = 14.dp, end = 16.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current,
onClick = { config.onClose(watchedCounter) },
)
.testTag(SwapStoriesScreenTestTags.CLOSE_BUTTON),
)
}
}
}
}

View file

@ -5,13 +5,18 @@ import kotlinx.collections.immutable.ImmutableList
/**
* Config for stories component
*
* @property stories configuration list
* @property stories configuration list
* @property isRestartable indicates than stories progressions starts
* @property isCloseButtonVisible whether the top-right close button (and back handler) is shown.
* Set `false` for non-closable stories (e.g. a root intro screen); [onClose] is then irrelevant.
* @property onClose invoked with the number of watched stories when the user closes them.
* Only meaningful when [isCloseButtonVisible] is `true`; defaults to a no-op for non-closable stories.
*/
interface StoriesContentConfig<T : StoryConfig> {
val stories: ImmutableList<T>
val isRestartable: Boolean
val onClose: (Int) -> Unit
val isCloseButtonVisible: Boolean get() = true
val onClose: (Int) -> Unit get() = {}
}
interface StoryConfig {

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -32,6 +33,8 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction
@ -70,55 +73,92 @@ fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier
@Composable
private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
val rowModifier = modifier
.fillMaxWidth()
.clickable(onClick = state.onClick)
.testTag(TransactionHistoryItemTestTags.ITEM)
TangemRowContainer(
modifier = rowModifier,
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens2.x4,
vertical = TangemTheme.dimens2.x3,
),
Column(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = state.onClick)
.testTag(TransactionHistoryItemTestTags.ITEM),
) {
StatusCircle(
iconRes = state.iconRes,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(TangemTheme.dimens2.x10)
.testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix),
TangemRowContainer(
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens2.x4,
vertical = TangemTheme.dimens2.x3,
),
) {
StatusCircle(
iconRes = state.iconRes,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(TangemTheme.dimens2.x10)
.testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix),
)
TitleText(
title = state.title,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TransactionHistoryItemTestTags.TITLE),
)
SubtitleText(
subtitle = state.subtitle,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5),
)
state.amount?.let { amount ->
AmountText(
amount = amount,
status = state.status,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_TOP)
.testTag(TransactionHistoryItemTestTags.AMOUNT),
)
}
CurrencyText(
symbol = state.currencySymbol,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5)
.testTag(TransactionHistoryItemTestTags.CURRENCY),
)
}
state.warning?.let { warning ->
WarningLine(
warning = warning,
modifier = Modifier.padding(
start = TangemTheme.dimens2.x4,
end = TangemTheme.dimens2.x4,
bottom = TangemTheme.dimens2.x3,
),
)
}
}
}
@Composable
private fun WarningLine(warning: TextReference, modifier: Modifier = Modifier) {
val attention = TangemTheme.colors2.text.status.attention
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
Icon(
painter = painterResource(R.drawable.ic_alert_triangle_20),
contentDescription = null,
tint = attention,
modifier = Modifier.size(TangemTheme.dimens2.x5),
)
TitleText(
title = state.title,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TransactionHistoryItemTestTags.TITLE),
)
SubtitleText(
subtitle = state.subtitle,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5),
)
AmountText(
amount = state.amount,
status = state.status,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_TOP)
.testTag(TransactionHistoryItemTestTags.AMOUNT),
)
CurrencyText(
symbol = state.currencySymbol,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5)
.testTag(TransactionHistoryItemTestTags.CURRENCY),
Text(
text = warning.resolveReference(),
color = attention,
style = TangemTheme.typography2.captionMedium12,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@ -262,6 +302,21 @@ private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Mo
modifier = Modifier.fillMaxSize(),
)
}
is ContentSubtitle.Asset -> InlineImageSubtitle(
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.symbol),
color = tertiary,
afterIconColor = if (isFailed) tertiary else primary,
modifier = modifier,
) {
subtitle.icon?.let { iconState ->
CurrencyIcon(
state = iconState,
shouldDisplayNetwork = false,
withFixedSize = false,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@ -491,4 +546,73 @@ private fun Preview_TransactionItem_Swap() {
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TransactionItem_Express() {
TangemThemePreviewRedesign {
PreviewColumn(
items = listOf(
TransactionItemUM.Content(
txHash = "exp-swap-u",
amount = "-390.00",
currencySymbol = "USDT",
time = "",
status = Status.Unconfirmed,
direction = Direction.OUTGOING,
onClick = {},
iconRes = R.drawable.ic_exchange_vertical_24,
title = stringReference("Swapping"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.TO,
symbol = "POL",
icon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.ic_custom_token_44,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
timestamp = 0L,
warning = stringReference("KYC verification required by provider"),
),
TransactionItemUM.Content(
txHash = "exp-onramp-c",
amount = "+0.006339",
currencySymbol = "BTC",
time = "",
status = Status.Confirmed,
direction = Direction.INCOMING,
onClick = {},
iconRes = R.drawable.ic_tangem_card_24,
title = stringReference("Topped up"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.FROM,
symbol = "SEK",
icon = null,
),
timestamp = 0L,
),
TransactionItemUM.Content(
txHash = "exp-onramp-f",
amount = "0.006339",
currencySymbol = "BTC",
time = "",
status = Status.Failed,
direction = Direction.INCOMING,
onClick = {},
iconRes = R.drawable.ic_tangem_card_24,
title = stringReference("Top up failed"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.FROM,
symbol = "SEK",
icon = null,
),
timestamp = 0L,
),
),
)
}
}
// endregion

View file

@ -3,6 +3,7 @@ package com.tangem.core.ui.components.transactions.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
@ -22,12 +23,13 @@ sealed interface TransactionItemUM {
/**
* Content state.
*
* @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded
* @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded.
* `null` hides the numeric value while [currencySymbol] still shows.
* @property currencySymbol currency symbol shown alongside [amount], e.g. "BTC", "USDT"
*/
data class Content(
override val txHash: String,
val amount: String,
val amount: String?,
val currencySymbol: String,
val time: String,
val status: Status,
@ -37,6 +39,7 @@ sealed interface TransactionItemUM {
val title: TextReference,
val subtitle: ContentSubtitle,
val timestamp: Long,
val warning: TextReference? = null,
) : TransactionItemUM {
@Immutable
@ -95,6 +98,19 @@ sealed interface TransactionItemUM {
val deviceIconUM: DeviceIconUM,
) : ContentSubtitle
/**
* Counterparty asset ticker renders as "to/from: <icon> <SYMBOL>". Used for express rows
* (swap counterparty currency / onramp fiat), e.g. "to: ◎ POL" or "from: 🇸🇪 SEK".
*
* @property icon resolved counterparty currency icon, rendered via `CurrencyIcon`. `null` when no icon
* is available (e.g. onramp fiat carries no `CryptoCurrency`) the ticker then renders without a leading icon.
*/
data class Asset(
val direction: Direction,
val symbol: String,
val icon: CurrencyIconState?,
) : ContentSubtitle
enum class Direction { TO, FROM }
}

View file

@ -0,0 +1,297 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.glowring
import android.os.Build
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.TangemColors3
/**
* Design-system v2 (DS3) **Glow Ring** an animated angular-gradient halo that runs around a
* rounded-rect outline, like lights chasing along the border.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=4933-126&m=dev)
*
* @param modifier Modifier for the whole component; also defines its size when there is no [content].
* @param variant Color theme of the gradient see [TangemGlowRing.Variant].
* @param cornerRadius Corner radius of the ring; should match the radius of the wrapped surface.
* @param animated When `false`, the ring is rendered static (no rotation).
* @param quality Rendering strategy; defaults to [TangemGlowRing.Quality.Auto] (device-appropriate).
* Force [TangemGlowRing.Quality.LayeredStrokes] to preview the pre-Android-12 fallback on any device.
* @param contentDescription Accessibility label; pass a value when the ring conveys state (e.g. error),
* leave `null` when it is purely decorative.
* @param content Optional content drawn inside/over the ring.
*/
@Composable
fun TangemGlowRing(
modifier: Modifier = Modifier,
variant: TangemGlowRing.Variant = TangemGlowRing.Variant.Magic,
cornerRadius: Dp = 24.dp,
animated: Boolean = true,
quality: TangemGlowRing.Quality = TangemGlowRing.Quality.Auto,
contentDescription: String? = null,
content: @Composable BoxScope.() -> Unit = {},
) {
val resolved = remember(quality) { resolveQuality(quality) }
val stops = rememberGlowRingStops(variant, animated)
val metrics = remember {
GlowRingMetrics(coreWidth = 2.dp, ringWidth = 4.dp, blurMid = 8.dp, blurBottom = 16.dp)
}
val angle = if (animated) {
val transition = rememberInfiniteTransition(label = "glowRing")
val rotation by transition.animateFloat(
initialValue = GLOW_RING_START_ANGLE,
targetValue = 270f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 24_000,
easing = CubicBezierEasing(a = 0.1f, b = 0f, c = 0.9f, d = 1f),
),
repeatMode = RepeatMode.Restart,
),
label = "angle",
)
rotation
} else {
GLOW_RING_START_ANGLE
}
Box(
modifier = if (contentDescription != null) {
modifier.semantics { this.contentDescription = contentDescription }
} else {
modifier
},
) {
val ringModifier = Modifier.matchParentSize()
when (resolved) {
ResolvedGlowRingQuality.Blur -> BlurGlowRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
metrics = metrics,
modifier = ringModifier,
)
ResolvedGlowRingQuality.LayeredStrokes -> LayeredStrokesGlowRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
metrics = metrics,
modifier = ringModifier,
)
}
content()
}
}
/** Sweep start angle, also reused as the static angle when [TangemGlowRing] is not animated (Figma: -90°). */
private const val GLOW_RING_START_ANGLE = -90f
/**
* Resolves the gradient stops for [variant] from the DS3 `colors3.glow` tokens. The
* [TangemGlowRing.Variant.Magic] variant continuously ping-pongs between gradient A (`glow.magic`) and
* gradient B (`glow.magicBlend`) while [animated] is `true`; every other variant has a single static
* gradient.
*/
@Composable
private fun rememberGlowRingStops(variant: TangemGlowRing.Variant, animated: Boolean): List<Pair<Float, Color>> {
val glow = TangemTheme.colors3.glow
if (variant != TangemGlowRing.Variant.Magic || !animated) {
return variant.stops(glow)
}
val morphTransition = rememberInfiniteTransition(label = "glowRingMorph")
val mix by morphTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
// 6s A→B half-period; Reverse makes a 12s ping-pong (Figma morphDur = 12s).
animation = tween(durationMillis = 6_000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "morphMix",
)
return morphedMagicStops(glow.magic.steps(), glow.magicBlend.steps(), mix)
}
/** Public API surface of [TangemGlowRing]. */
object TangemGlowRing {
/** Color theme of the glow ring gradient. */
enum class Variant {
/**
* Multi-color "magic" gradient that continuously auto-morphs (ping-pongs) between separated
* orange / blue / purple arcs and a continuous fully-saturated blend.
*/
Magic,
/** Green success glow. */
Success,
/** Red error glow. */
Error,
/** Orange/amber warning glow. */
Warning,
/** Blue informational glow. */
Info,
}
/**
* Rendering strategy for the glow.
*
* [Auto] picks the best renderer for the current device a real Gaussian blur on Android 12+
* (API 31) and a layered-stroke approximation on older versions. The explicit values force one
* renderer regardless of API level; they exist mainly for previews / Storybook so the
* pre-Android-12 fallback can be inspected on a modern device. Product code should use [Auto].
*/
enum class Quality {
/** Auto-detect the renderer from the device API level (recommended). */
Auto,
/** Force the Android 12+ real-blur renderer. */
Blur,
/** Force the pre-Android-12 layered-stroke fallback. */
LayeredStrokes,
}
}
/**
* Resolves [quality] to a concrete renderer. [TangemGlowRing.Quality.Auto] picks a real blur on
* Android 12+ (API 31) and falls back to stacked translucent strokes on older versions; the explicit
* values force their renderer regardless of API level.
*/
private fun resolveQuality(quality: TangemGlowRing.Quality): ResolvedGlowRingQuality = when (quality) {
TangemGlowRing.Quality.Blur -> ResolvedGlowRingQuality.Blur
TangemGlowRing.Quality.LayeredStrokes -> ResolvedGlowRingQuality.LayeredStrokes
TangemGlowRing.Quality.Auto -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ResolvedGlowRingQuality.Blur
} else {
ResolvedGlowRingQuality.LayeredStrokes
}
}
/** Concrete rendering strategy chosen by [resolveQuality]. */
private enum class ResolvedGlowRingQuality { Blur, LayeredStrokes }
/**
* Builds the angular-gradient stops for [this] variant from its DS3 `colors3.glow` token group. Every
* variant token exposes the same 10 [steps] solid arcs at steps 1/4/7, a faint arc at 9 and transparent
* gaps elsewhere which [glowStops] lays out as evenly-spaced, seamlessly-looping stops.
*/
private fun TangemGlowRing.Variant.stops(glow: TangemColors3.Glow): List<Pair<Float, Color>> = glowStops(
when (this) {
TangemGlowRing.Variant.Magic -> glow.magic.steps()
TangemGlowRing.Variant.Success -> glow.success.steps()
TangemGlowRing.Variant.Error -> glow.error.steps()
TangemGlowRing.Variant.Warning -> glow.warning.steps()
TangemGlowRing.Variant.Info -> glow.info.steps()
},
)
/**
* Blends the Magic gradients A ([magic] = `glow.magic`) and B ([magicBlend] = `glow.magicBlend`) at
* [mix] (`0` = A, `1` = B). Both token groups share the same stop positions, so the morph is a direct
* per-step color lerp. Mirrors the reference rig's auto-morph (ping-pong) between gradient A and B.
*/
private fun morphedMagicStops(magic: List<Color>, magicBlend: List<Color>, mix: Float): List<Pair<Float, Color>> {
val m = mix.coerceIn(0f, 1f)
return glowStops(List(magic.size) { lerp(magic[it], magicBlend[it], m) })
}
/**
* Lays the glow [steps] out as an angular gradient: evenly spaced from `0`, with step 1 repeated at `1.0`
* so the rotation loops seamlessly. Transparent steps create the gaps between the glowing arcs.
*/
private fun glowStops(steps: List<Color>): List<Pair<Float, Color>> {
val count = steps.size
return steps.mapIndexed { index, color -> index.toFloat() / count to color } + (1f to steps.first())
}
private fun TangemColors3.Glow.Magic.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.MagicBlend.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Success.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Error.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Warning.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
private fun TangemColors3.Glow.Info.steps(): List<Color> =
listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10)
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemGlowRingPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Magic,
)
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Success,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Error,
)
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Warning,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
TangemGlowRing(
modifier = Modifier.size(120.dp, 72.dp),
variant = TangemGlowRing.Variant.Info,
)
}
}
}
}

View file

@ -0,0 +1,231 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.glowring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.Dp
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
/**
* Token-driven measurements shared by both renderers, mirroring the Figma component anatomy:
* a crisp [coreWidth] core line plus two wider, blurred glow bands ([ringWidth] stroked, blurred by
* [blurMid] and [blurBottom]).
*/
internal data class GlowRingMetrics(
val coreWidth: Dp, // crisp core stroke (top layer)
val ringWidth: Dp, // glow band stroke (mid + bottom layers)
val blurMid: Dp, // mid glow blur radius
val blurBottom: Dp, // widest glow blur radius
)
/**
* Tier 1 works on every API level, no blur or shader. Approximates the blurred glow by stacking the
* same breathing angular-gradient ring several times: progressively wider + fainter bands under a crisp
* core. Everything is clipped to the rounded box, so only the inner half of each band shows inner glow.
*/
@Composable
internal fun LayeredStrokesGlowRing(
angle: Float,
stops: List<Pair<Float, Color>>,
cornerRadius: Dp,
metrics: GlowRingMetrics,
modifier: Modifier = Modifier,
) {
Box(modifier.clip(RoundedCornerShape(cornerRadius))) {
Canvas(Modifier.fillMaxSize()) {
val r = cornerRadius.toPx()
// widest & faintest first, crisp core last
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = r,
strokePx = (metrics.ringWidth + metrics.blurBottom).toPx(),
alpha = 0.06f,
)
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = r,
strokePx = (metrics.ringWidth + metrics.blurMid).toPx(),
alpha = 0.12f,
)
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = r,
strokePx = metrics.ringWidth.toPx(),
alpha = 0.30f,
)
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = r,
strokePx = metrics.coreWidth.toPx(),
alpha = 1.0f,
)
}
}
}
/**
* Tier 2 Android 12+ (API 31). Reproduces the Figma anatomy directly: three stacked breathing
* angular-gradient rings with real blur (bottom widest, mid, top crisp). Each layer bleeds with
* [BlurredEdgeTreatment.Unbounded]; the surrounding [clip] to the rounded box keeps only the inner
* bloom, producing the inner glow.
*/
@Composable
internal fun BlurGlowRing(
angle: Float,
stops: List<Pair<Float, Color>>,
cornerRadius: Dp,
metrics: GlowRingMetrics,
modifier: Modifier = Modifier,
) {
Box(modifier.clip(RoundedCornerShape(cornerRadius))) {
// bottom — widest halo
BreathingRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
strokeWidth = metrics.ringWidth,
modifier = Modifier
.fillMaxSize()
.blur(radius = metrics.blurBottom, edgeTreatment = BlurredEdgeTreatment.Unbounded),
)
// mid
BreathingRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
strokeWidth = metrics.ringWidth,
modifier = Modifier
.fillMaxSize()
.blur(radius = metrics.blurMid, edgeTreatment = BlurredEdgeTreatment.Unbounded),
)
// top — crisp core
BreathingRing(
angle = angle,
stops = stops,
cornerRadius = cornerRadius,
strokeWidth = metrics.coreWidth,
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun BreathingRing(
angle: Float,
stops: List<Pair<Float, Color>>,
cornerRadius: Dp,
strokeWidth: Dp,
modifier: Modifier = Modifier,
) {
Canvas(modifier) {
drawBreathingRing(
stops = stops,
angleDeg = angle,
cornerRadiusPx = cornerRadius.toPx(),
strokePx = strokeWidth.toPx(),
alpha = 1f,
)
}
}
/**
* Draws one angular-gradient ring band clipped to the rounded-rect stroke outline. The gradient is a
* sweep whose colour seam is rotated by [angleDeg] (via [rotatedStops]) and whose vertical squish
* breathes between W/2 and W/8 over the rotation (`rxM = mid + amp·cos(2φ)`), reproducing the morphing
* arcs of the reference rig.
*/
private fun DrawScope.drawBreathingRing(
stops: List<Pair<Float, Color>>,
angleDeg: Float,
cornerRadiusPx: Float,
strokePx: Float,
alpha: Float,
) {
val w = size.width
val h = size.height
if (w <= 0f || h <= 0f) return
val center = Offset(w / 2f, h / 2f)
// Breathing horizontal radius of the gradient ellipse → vertical squish of the angle sampling.
val maxRx = w / 2f
val minRx = w / 8f
val mid = (maxRx + minRx) / 2f
val amp = max((maxRx - minRx) / 2f, 0f)
val phaseRad = Math.toRadians(angleDeg.toDouble()).toFloat()
val rxM = mid + amp * cos(2f * phaseRad)
val scaleY = h / 2f / max(rxM, 1f)
val r = min(cornerRadiusPx, min(w, h) / 2f)
val o = strokePx / 2f
val ring = Path().apply {
fillType = PathFillType.EvenOdd
addRoundRect(
RoundRect(rect = Rect(Offset(-o, -o), Size(w + 2f * o, h + 2f * o)), cornerRadius = CornerRadius(r + o)),
)
addRoundRect(
RoundRect(
rect = Rect(Offset(o, o), Size(w - 2f * o, h - 2f * o)),
cornerRadius = CornerRadius(max(r - o, 0f)),
),
)
}
val brush = Brush.sweepGradient(colorStops = rotatedStops(stops, angleDeg), center = center)
val big = max(w, h) * 4f
clipPath(ring) {
withTransform({ scale(scaleX = 1f, scaleY = scaleY, pivot = center) }) {
drawRect(
brush = brush,
topLeft = Offset(center.x - big / 2f, center.y - big / 2f),
size = Size(big, big),
alpha = alpha,
)
}
}
}
/**
* Compose's [Brush.sweepGradient] has no start-angle parameter, so the colour seam is rotated by
* shifting every stop position by `deg/360` (wrapping around the loop) and re-anchoring boundary stops
* at 0 and 1 with the interpolated wrap colour. Mirrors `rotatedStops` from the reference rig.
*/
private fun rotatedStops(base: List<Pair<Float, Color>>, deg: Float): Array<Pair<Float, Color>> {
val d = (deg / 360f % 1f + 1f) % 1f
val uniq = base.dropLast(1) // drop the duplicate wrap stop at 1.0
val shifted = uniq
.map { (p, c) -> ((p + d) % 1f + 1f) % 1f to c }
.sortedBy { it.first }
val first = shifted.first()
val last = shifted.last()
val span = first.first + 1f - last.first
val wrapFraction = if (span > 1e-6f) (1f - last.first) / span else 0f
val wrapColor = lerp(last.second, first.second, wrapFraction)
return (listOf(0f to wrapColor) + shifted + listOf(1f to wrapColor)).toTypedArray()
}

View file

@ -52,11 +52,13 @@ open class BigDecimalCryptoFormatStyled(
fun BigDecimalFormatScope.crypto(
symbol: String,
decimals: Int,
ignoreSymbolPosition: Boolean = false,
locale: Locale = Locale.getDefault(),
): BigDecimalCryptoFormat {
return BigDecimalCryptoFormat(
symbol = symbol,
decimals = decimals,
shouldIgnoreSymbolPosition = ignoreSymbolPosition,
locale = locale,
)
}

View file

@ -190,11 +190,13 @@ object TangemTheme {
@ReadOnlyComposable
get() = LocalTangemTypography3.current
@Deprecated("Use plain dp")
val dimens: TangemDimens
@Composable
@ReadOnlyComposable
get() = LocalTangemDimens.current
@Deprecated("Use plain dp")
val dimens2: TangemDimens2
@Composable
@ReadOnlyComposable

View file

@ -2,4 +2,5 @@ package com.tangem.core.ui.test
object AppSettingsScreenTestTags {
const val CURRENCY_BUTTON = "APP_SETTINGS_SCREEN_CURRENCY_BUTTON"
const val BACK_BUTTON = "APP_SETTINGS_SCREEN_BACK_BUTTON"
}

View file

@ -0,0 +1,25 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M17.213,3.014C18.508,3.147 19.5,4.257 19.5,5.583V18.417C19.5,19.831 18.372,21 16.95,21H7.05C5.629,21 4.5,19.831 4.5,18.417V17.895H3C2.448,17.895 2,17.447 2,16.895C2,16.342 2.448,15.894 3,15.894H4.5V12.984H3C2.448,12.984 2,12.536 2,11.984C2,11.432 2.448,10.984 3,10.984H4.5V8.075H3C2.448,8.075 2,7.627 2,7.075C2,6.523 2.448,6.075 3,6.075H4.5V5.583C4.5,4.169 5.628,2.999 7.05,2.999H16.95L17.213,3.014ZM10.61,12.887C9.492,12.89 8.573,13.739 8.46,14.828L8.448,15.049V15.325C8.448,15.614 8.563,15.891 8.767,16.095C8.971,16.299 9.248,16.413 9.536,16.413H14.465C15.065,16.413 15.552,15.926 15.552,15.325V15.049C15.549,13.931 14.7,13.012 13.611,12.898L13.39,12.887H10.61ZM12,7.588C10.84,7.588 9.899,8.528 9.899,9.688C9.9,10.849 10.84,11.789 12,11.789C13.16,11.789 14.1,10.849 14.101,9.688C14.101,8.528 13.16,7.588 12,7.588Z"
android:fillColor="#0090F9"
android:fillType="evenOdd"/>
</vector>

View file

@ -1,9 +0,0 @@
<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="M9.149,16.445C9.427,16.482 9.711,16.5 10,16.5V18C9.646,18 9.297,17.974 8.955,17.93L9.149,16.445ZM10.947,17.188L11.007,17.641L11.044,17.93C10.702,17.974 10.354,18 10,18V16.5C10.289,16.5 10.573,16.482 10.851,16.445L10.947,17.188ZM6.044,15.158C6.492,15.502 6.985,15.789 7.513,16.008L6.938,17.392C6.287,17.122 5.681,16.769 5.131,16.346L6.044,15.158ZM14.868,16.346C14.318,16.769 13.712,17.122 13.061,17.392L12.997,17.234L12.487,16.008C13.015,15.789 13.508,15.502 13.956,15.158L14.868,16.346ZM3.992,12.487C4.211,13.015 4.498,13.508 4.842,13.956L4.247,14.413L3.653,14.868C3.231,14.318 2.877,13.712 2.607,13.061L2.772,12.994L3.992,12.487ZM17.228,12.994L17.392,13.061C17.122,13.712 16.769,14.318 16.346,14.868L15.158,13.956C15.502,13.508 15.789,13.015 16.008,12.487L17.228,12.994ZM2,10C2,9.646 2.025,9.297 2.069,8.955L2.812,9.052L3.555,9.149C3.518,9.427 3.5,9.711 3.5,10C3.5,10.289 3.518,10.573 3.555,10.851L2.069,11.044C2.025,10.702 2,10.354 2,10ZM18,10C18,10.354 17.974,10.702 17.93,11.044L17.188,10.948L16.445,10.851C16.482,10.573 16.5,10.289 16.5,10C16.5,9.711 16.482,9.427 16.445,9.149L17.93,8.955C17.974,9.297 18,9.646 18,10ZM4.842,6.044C4.498,6.492 4.211,6.985 3.992,7.513L2.607,6.938C2.877,6.287 3.231,5.681 3.653,5.131L4.842,6.044ZM16.346,5.131C16.769,5.681 17.122,6.287 17.392,6.938L16.008,7.513C15.789,6.985 15.502,6.492 15.158,6.044L16.346,5.131ZM7.513,3.992C6.985,4.211 6.492,4.498 6.044,4.842L5.131,3.653C5.681,3.231 6.287,2.877 6.938,2.607L7.513,3.992ZM13.061,2.607C13.712,2.877 14.318,3.231 14.868,3.653L13.956,4.842C13.508,4.498 13.015,4.211 12.487,3.992L13.061,2.607ZM10,2C10.354,2 10.702,2.025 11.044,2.069L10.948,2.812L10.947,2.811L10.851,3.555C10.573,3.518 10.289,3.5 10,3.5C9.711,3.5 9.427,3.518 9.149,3.555L8.955,2.069C9.297,2.025 9.646,2 10,2Z"
android:fillColor="#0090F9"/>
</vector>