Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-27 18:47:01 +04:00
commit 63ab7f4037
42 changed files with 2532 additions and 307 deletions

View file

@ -9,6 +9,11 @@
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
},
"notion": {
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"]
}
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig
@ -49,4 +50,6 @@ interface ApplicationEntryPoint {
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor
fun getDeviceKeyManager(): DeviceKeyManager
}

View file

@ -21,6 +21,7 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.common.LogConfig
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
@ -92,6 +93,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val sendTransactionSignerInfoInterceptor
get() = entryPoint.getSendTransactionSignerInfoInterceptor()
private val deviceKeyManager: DeviceKeyManager
get() = entryPoint.getDeviceKeyManager()
// endregion
private val appScope = MainScope()
@ -132,6 +136,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
}
fun init() {
appScope.launch {
deviceKeyManager.generateIfMissing()
}
walletsRepository = entryPoint.getWalletsRepository()
apiConfigsManager.initialize()

View file

@ -4,13 +4,7 @@ import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
@ -36,20 +30,12 @@ import com.tangem.common.ui.earn.EarnBlockUM.Type
import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.ds.button.TangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.button.*
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
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.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.res.R as CoreResR
@ -74,7 +60,7 @@ fun EarnBlock(state: EarnBlockUM, modifier: Modifier = Modifier) {
@Composable
private fun EarnBlockLoading(modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(TangemTheme.dimens2.x4)
val shape = RoundedCornerShape(TangemTheme.dimens2.x5)
TangemRowContainer(
modifier = modifier
.clip(shape)
@ -91,13 +77,13 @@ private fun EarnBlockLoading(modifier: Modifier = Modifier) {
RectangleShimmer(
modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5),
.size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4),
radius = TangemTheme.dimens2.x2,
)
RectangleShimmer(
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4),
.size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5),
radius = TangemTheme.dimens2.x2,
)
},
@ -106,7 +92,7 @@ private fun EarnBlockLoading(modifier: Modifier = Modifier) {
@Composable
private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(TangemTheme.dimens2.x4)
val shape = RoundedCornerShape(TangemTheme.dimens2.x5)
val clickModifier = state.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
@ -114,7 +100,7 @@ private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Mo
modifier = modifier
.clip(shape)
.then(clickModifier.backgroundModifier(state.type, state.backgroundUM, shape)),
contentPadding = PaddingValues(all = TangemTheme.dimens2.x3),
contentPadding = PaddingValues(all = TangemTheme.dimens2.x4),
content = {
EarnBlockIcon(
type = state.type,
@ -361,7 +347,7 @@ private val EarnBlockUM.TitleUM.Style.textStyle: TextStyle
@Composable
@ReadOnlyComposable
get() = when (this) {
EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16
EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodyMedium16
EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12
}
@ -369,7 +355,7 @@ private val EarnBlockUM.SubtitleUM.Style.textStyle: TextStyle
@Composable
@ReadOnlyComposable
get() = when (this) {
EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16
EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodyMedium16
EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12
}
// endregion
@ -410,12 +396,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid
iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_stake),
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,
@ -426,12 +412,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_staking),
style = EarnBlockUM.TitleUM.Style.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stringReference("Average APR 5.24%"),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
@ -445,12 +431,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.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 = EarnBlockUM.SubtitleUM.Text(
text = stringReference("$ 12.34 rewards"),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Balance(
@ -475,14 +461,14 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
id = CoreResR.string.yield_module_token_details_earn_notification_subtitle,
formatArgs = wrappedList("5.24"),
),
style = EarnBlockUM.TitleUM.Style.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(
CoreResR.string.yield_module_token_details_earn_notification_description,
),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
@ -497,7 +483,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.yield_module_transaction_enter),
style = EarnBlockUM.TitleUM.Style.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
@ -505,7 +491,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
@ -521,7 +507,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Warning),
),
@ -530,7 +516,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
@ -546,7 +532,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Info),
),
@ -555,7 +541,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
@ -571,12 +557,12 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.common_enabling),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive),
),
@ -589,12 +575,12 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr
iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_yield_disabling_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.common_disabling),
style = EarnBlockUM.SubtitleUM.Style.Small,
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted),
),

View file

@ -14,6 +14,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
@ -61,10 +62,8 @@ fun TokenActionRow(
val accentColor = accentColor(isEnabled)
TangemRowContainer(
modifier = modifier
.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
)
.clip(RoundedCornerShape(TangemTheme.dimens2.x5))
.background(color = TangemTheme.colors2.surface.level3)
.clickableWithHaptic(
onClick = onClick,
onLongClick = onLongClick,

View file

@ -17,7 +17,7 @@
},
{
"name": "APP_REDESIGN_ENABLED",
"version": "undefined"
"version": "5.40"
},
{
"name": "GASLESS_APPROVAL_ENABLED",
@ -106,5 +106,9 @@
{
"name": "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED",
"version": "5.39"
},
{
"name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED",
"version": "undefined"
}
]

View file

@ -93,6 +93,39 @@ fun Modifier.topFade(
solidStop = solidStop,
)
/**
* Draws a vertical gradient fade over the top edge with custom [colorStops].
*
* Stops are defined relative to [height] (0f = top, 1f = bottom of the fade region).
*
* Note: the gradient is drawn over the entire content area, so the last color stop's color
* extends below the fade region down to the bottom. Pass [Color.Transparent] as the last stop
* (or a color matching the underlying content) to avoid covering the area outside the fade.
*/
@Composable
fun Modifier.topFade(height: Dp, vararg colorStops: Pair<Float, Color>): Modifier = composed {
drawWithContent {
drawContent()
if (this.size.height <= 0f) return@drawWithContent
val fraction = (height.toPx() / this.size.height).coerceIn(0f, 1f)
if (fraction <= 0f) return@drawWithContent
val (start, end) = this.size.getFadeOffsets(FadePosition.TOP)
drawRect(
brush = Brush.linearGradient(
colorStops = colorStops
.map { (stop, color) -> stop.coerceIn(0f, 1f) * fraction to color }
.toTypedArray(),
start = start,
end = end,
),
size = this.size,
)
}
}
enum class FadePosition {
TOP, BOTTOM, LEFT, RIGHT
}

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -28,9 +29,10 @@ import androidx.compose.ui.unit.sp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
enum class AccountIconSize {
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault
Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall
}
/**
@ -129,6 +131,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
AccountIconSize.Small -> TangemTheme.typography.subtitle2
AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1
AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28
AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11
}
val textSize by animateFloatAsState(
@ -148,6 +151,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
text = char.uppercase(),
style = textStyle.copy(fontSize = textSize.sp),
color = TangemTheme.colors.text.constantWhite,
textAlign = TextAlign.Center,
)
}
}
@ -161,6 +165,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) {
AccountIconSize.Small -> 12.dp
AccountIconSize.ExtraSmall -> 8.dp
AccountIconSize.RedesignedDefault -> 20.dp
AccountIconSize.RedesignExtraSmall -> 8.dp
}
fun AccountIconSize.toBoxSize(): Dp = when (this) {
@ -170,6 +175,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) {
AccountIconSize.Small -> 20.dp
AccountIconSize.ExtraSmall -> 14.dp
AccountIconSize.RedesignedDefault -> 40.dp
AccountIconSize.RedesignExtraSmall -> 16.dp
}
private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
@ -179,6 +185,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
AccountIconSize.Small -> 6.dp
AccountIconSize.ExtraSmall -> 4.dp
AccountIconSize.RedesignedDefault -> 12.dp
AccountIconSize.RedesignExtraSmall -> 6.dp
}
@Preview(showBackground = true)
@ -194,6 +201,19 @@ private fun Preview() {
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewRedesigned() {
TangemThemePreviewRedesign {
Row(
modifier = Modifier.background(TangemTheme.colors.background.primary),
) {
Sample()
}
}
}
@Composable
private fun Sample() {
var sizeState by remember { mutableStateOf(AccountIconSize.ExtraSmall) }
@ -206,8 +226,9 @@ private fun Sample() {
AccountIconSize.Large -> AccountIconSize.Medium
AccountIconSize.Medium -> AccountIconSize.Small
AccountIconSize.Small -> AccountIconSize.ExtraSmall
AccountIconSize.ExtraSmall -> AccountIconSize.Default
AccountIconSize.RedesignedDefault -> AccountIconSize.Large
AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault
AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall
AccountIconSize.RedesignExtraSmall -> AccountIconSize.Default
}
}) { Text("Change") }
@ -225,5 +246,6 @@ private fun Sample() {
AccountCharIcon(char = 'M', color = Color.Magenta, size = AccountIconSize.Medium)
AccountCharIcon(char = 'S', color = Color.DarkGray, size = AccountIconSize.Small)
AccountCharIcon(char = 'E', color = Color.Green, size = AccountIconSize.ExtraSmall)
AccountCharIcon(char = 'D', color = Color.LightGray, size = AccountIconSize.RedesignExtraSmall)
}
}

View file

@ -0,0 +1,166 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.fade
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
private const val SOLID_RATIO = 40f / 96f
private const val HARD_ALPHA = 0.95f
private const val SOFT_ALPHA = 0.6f
private val FADE_HEIGHT = 96.dp
private val BLUR_RADIUS = 20.dp
/**
* Design-system fade overlay used at the top or bottom edge of scrollable content.
*
* Renders a 96dp-tall gradient (and optional solid block in the [TangemFade.Variant.Hard]
* variant) tinted with `colors3.bg.primary`, so it blends with the page background and masks
* content as it scrolls under the edge. Place it inside a [Box] and align with
* [androidx.compose.ui.Alignment.TopCenter] / [androidx.compose.ui.Alignment.BottomCenter]
* depending on [position].
*
* @param position which edge the fade is anchored to controls the gradient direction.
* @param variant [TangemFade.Variant.Hard] adds an opaque solid block next to the edge for a
* harder cut-off, [TangemFade.Variant.Soft] is gradient-only and gentler.
* @param blur when `true`, the content under the fade is blurred via Haze (radius 20dp,
* progressive intensity matching [position]).
* @param backgroundColor base color of the fade gradient defaults to `colors3.bg.primary` so
* the fade blends with the standard page background.
* @param modifier modifier applied to the fade's root.
*/
@Composable
fun TangemFade(
position: TangemFade.Position,
modifier: Modifier = Modifier,
variant: TangemFade.Variant = TangemFade.Variant.Soft,
blur: Boolean = false,
backgroundColor: Color = TangemTheme.colors3.bg.primary,
) {
val hazeState = LocalHazeState.current
val brush = buildBrush(color = backgroundColor, position = position, variant = variant)
val blurModifier = if (blur) {
Modifier.hazeEffectTangem(state = hazeState) {
blurRadius = BLUR_RADIUS
fallbackTint = HazeTint(Color.Transparent)
progressive = HazeProgressive.verticalGradient(
startIntensity = if (position == TangemFade.Position.Top) 1f else 0f,
endIntensity = if (position == TangemFade.Position.Top) 0f else 1f,
preferPerformance = true,
)
}
} else {
Modifier
}
Box(
modifier = modifier
.fillMaxWidth()
.height(FADE_HEIGHT)
.then(blurModifier)
.background(brush),
)
}
private fun buildBrush(color: Color, position: TangemFade.Position, variant: TangemFade.Variant): Brush {
val opaque = color.copy(alpha = HARD_ALPHA)
val soft = color.copy(alpha = SOFT_ALPHA)
val transparent = Color.Transparent
return when (variant) {
TangemFade.Variant.Hard -> when (position) {
TangemFade.Position.Top -> Brush.verticalGradient(
colorStops = arrayOf(
0f to opaque,
SOLID_RATIO to opaque,
1f to transparent,
),
)
TangemFade.Position.Bottom -> Brush.verticalGradient(
colorStops = arrayOf(
0f to transparent,
1f - SOLID_RATIO to opaque,
1f to opaque,
),
)
}
TangemFade.Variant.Soft -> when (position) {
TangemFade.Position.Top -> Brush.verticalGradient(colors = listOf(soft, transparent))
TangemFade.Position.Bottom -> Brush.verticalGradient(colors = listOf(transparent, soft))
}
}
}
object TangemFade {
enum class Position { Top, Bottom }
enum class Variant { Hard, Soft }
}
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemFade_Preview() {
TangemThemePreviewRedesign {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.background(TangemTheme.colors3.bg.secondary)
.padding(12.dp),
) {
TangemFade.Variant.entries.forEach { variant ->
TangemFade.Position.entries.forEach { position ->
FadePreviewRow(variant = variant, position = position)
}
}
}
}
}
@Composable
private fun FadePreviewRow(variant: TangemFade.Variant, position: TangemFade.Position) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "${variant.name} / ${position.name}",
style = TangemTheme.typography.caption2,
color = TangemTheme.colors3.text.primary,
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(TangemTheme.colors3.bg.accent.blue),
) {
TangemFade(
position = position,
variant = variant,
modifier = Modifier.align(
when (position) {
TangemFade.Position.Top -> Alignment.TopCenter
TangemFade.Position.Bottom -> Alignment.BottomCenter
},
),
)
}
}
}

View file

@ -0,0 +1,468 @@
package com.tangem.core.ui.ds2.row
import android.content.res.Configuration
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
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.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.RippleConfiguration
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
/** Controls how the title and value sides share horizontal space in a [TangemRow]. */
@Immutable
enum class TangemRowContentLead { Equal, Start, End }
/** Vertical alignment between the start slot, content labels, and end slot in a [TangemRow]. */
@Immutable
enum class TangemRowVerticalAlignment { Top, Center }
/**
* Design-system v2 row: a list-item container with start / end slots, title+subtitle on the
* leading side, value+subvalue on the trailing side, and an optional slot below the row.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=2344-1406)
*
* Usage:
* ```
* TangemRow(
* titleSlot = { TangemRowText("Network fee", TangemRowTextRole.Title) },
* valueSlot = { TangemRowText("0.0001 ETH", TangemRowTextRole.Value) },
* contentLead = TangemRowContentLead.Start,
* onClick = { /* ... */ },
* )
* ```
*
* @param modifier Modifier applied to the row container.
* @param divider Draws an inset bottom divider.
* @param includeInnerPaddings Applies the default row padding. Disable when wrapped in a container
* that already handles padding.
* @param contentLead Strategy for sharing space between the title and value sides.
* See [TangemRowContentLead].
* @param verticalAlignment Vertical alignment of the slots. See [TangemRowVerticalAlignment].
* @param titleSlot Leading primary label.
* @param subtitleSlot Leading secondary label rendered under [titleSlot].
* @param valueSlot Trailing primary label.
* @param subvalueSlot Trailing secondary label rendered under [valueSlot].
* @param startSlot Leading icon / control slot.
* @param endSlot Trailing icon / control slot.
* @param extraBottomSlot Full-width content rendered below the row.
* @param interactionSource Interaction source used when [onClick] is non-null.
* @param onClick Click handler. `null` makes the row non-interactive.
*/
@Suppress("LongParameterList")
@Composable
fun TangemRow(
modifier: Modifier = Modifier,
divider: Boolean = false,
includeInnerPaddings: Boolean = true,
contentLead: TangemRowContentLead = TangemRowContentLead.Equal,
verticalAlignment: TangemRowVerticalAlignment = TangemRowVerticalAlignment.Top,
titleSlot: (@Composable RowScope.() -> Unit)? = null,
subtitleSlot: (@Composable RowScope.() -> Unit)? = null,
valueSlot: (@Composable RowScope.() -> Unit)? = null,
subvalueSlot: (@Composable RowScope.() -> Unit)? = null,
startSlot: (@Composable BoxScope.() -> Unit)? = null,
endSlot: (@Composable BoxScope.() -> Unit)? = null,
extraBottomSlot: (@Composable () -> Unit)? = null,
interactionSource: MutableInteractionSource? = null,
onClick: (() -> Unit)? = null,
) {
val resolvedInteractionSource: MutableInteractionSource? = if (onClick != null) {
interactionSource ?: remember { MutableInteractionSource() }
} else {
null
}
val isFocused = if (resolvedInteractionSource != null) {
val isFocusedByState by resolvedInteractionSource.collectIsFocusedAsState()
isFocusedByState
} else {
false
}
WithRowRipple(enabled = onClick != null) {
Column(
modifier = modifier
.conditionalCompose(isFocused) { focusBorder() }
.then(
if (onClick != null) {
Modifier.clickable(
interactionSource = resolvedInteractionSource,
indication = LocalIndication.current,
onClick = onClick,
)
} else {
Modifier
},
)
.conditionalCompose(divider) {
bottomDivider(
color = TangemTheme.colors3.border.secondary,
width = TangemTheme.dimens3.borderWidth.sm,
horizontalInset = TangemTheme.dimens3.spacing.s200,
)
}
.conditionalCompose(includeInnerPaddings) {
padding(TangemTheme.dimens3.spacing.s200)
},
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = verticalAlignment.toCompose(),
) {
SideSlot(slot = startSlot, position = SideSlotPosition.Start)
ContentLabels(
modifier = Modifier.weight(1f),
contentLead = contentLead,
verticalAlignment = verticalAlignment,
titleSlot = titleSlot,
subtitleSlot = subtitleSlot,
valueSlot = valueSlot,
subvalueSlot = subvalueSlot,
)
SideSlot(slot = endSlot, position = SideSlotPosition.End)
}
if (extraBottomSlot != null) {
SpacerH(TangemTheme.dimens3.spacing.s100)
extraBottomSlot()
}
}
}
}
@Composable
private fun WithRowRipple(enabled: Boolean, content: @Composable () -> Unit) {
if (enabled) {
CompositionLocalProvider(LocalRippleConfiguration provides tangemRowRipple(), content = content)
} else {
content()
}
}
@Composable
@ReadOnlyComposable
private fun tangemRowRipple(): RippleConfiguration = RippleConfiguration(
color = TangemTheme.colors3.interaction.press.default,
rippleAlpha = RippleAlpha(
draggedAlpha = 0f,
focusedAlpha = 0f,
hoveredAlpha = 0.05f,
pressedAlpha = 0.1f,
),
)
private fun TangemRowVerticalAlignment.toCompose(): Alignment.Vertical = when (this) {
TangemRowVerticalAlignment.Top -> Alignment.Top
TangemRowVerticalAlignment.Center -> Alignment.CenterVertically
}
private enum class SideSlotPosition { Start, End }
@Composable
private fun SideSlot(slot: (@Composable BoxScope.() -> Unit)?, position: SideSlotPosition) {
if (slot == null) return
val spacing = TangemTheme.dimens3.spacing.s150
val padding = when (position) {
SideSlotPosition.Start -> Modifier.padding(end = spacing)
SideSlotPosition.End -> Modifier.padding(start = spacing)
}
Box(modifier = padding, content = slot)
}
/**
* Lays out the title/value columns side-by-side with [contentLead]-aware width sharing.
*/
@Suppress("UnnecessaryParentheses", "LongParameterList")
@Composable
private fun ContentLabels(
contentLead: TangemRowContentLead,
verticalAlignment: TangemRowVerticalAlignment,
titleSlot: (@Composable RowScope.() -> Unit)?,
subtitleSlot: (@Composable RowScope.() -> Unit)?,
valueSlot: (@Composable RowScope.() -> Unit)?,
subvalueSlot: (@Composable RowScope.() -> Unit)?,
modifier: Modifier = Modifier,
) {
val hasLeft = titleSlot != null || subtitleSlot != null
val hasRight = valueSlot != null || subvalueSlot != null
if (!hasLeft && !hasRight) return
Layout(
modifier = modifier,
content = {
// Always emit two roots so `measurables` indices are stable in the measure block.
LabelColumnContent(primary = titleSlot, secondary = subtitleSlot, alignment = Alignment.Start)
LabelColumnContent(primary = valueSlot, secondary = subvalueSlot, alignment = Alignment.End)
},
) { measurables, constraints ->
val titleMeasurable = measurables[0]
val valueMeasurable = measurables[1]
// Fall back to summed intrinsics when parent provides unbounded width — `layout()` cannot
// report an infinite size.
val rowWidth = if (constraints.hasBoundedWidth) {
constraints.maxWidth
} else {
titleMeasurable.maxIntrinsicWidth(Int.MAX_VALUE) + valueMeasurable.maxIntrinsicWidth(Int.MAX_VALUE)
}
val (titleSlotWidth, valueSlotWidth) = when {
!hasLeft -> 0 to rowWidth
!hasRight -> rowWidth to 0
else -> when (contentLead) {
TangemRowContentLead.Equal -> (rowWidth / 2) to (rowWidth - rowWidth / 2)
TangemRowContentLead.Start -> {
// Value hugs (intrinsic), title fills the rest. Cap the hugging side at half
// the row so the filling label never collapses to zero on overflow — degrades
// to an equal split when the hugging side wants more than half.
val v = valueMeasurable.maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(rowWidth / 2)
(rowWidth - v) to v
}
TangemRowContentLead.End -> {
// Title hugs (intrinsic), value fills the rest. Same half-row cap as above.
val t = titleMeasurable.maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(rowWidth / 2)
t to (rowWidth - t)
}
}
}
val titlePlaceable = titleMeasurable.measure(
constraints.copy(minWidth = 0, maxWidth = titleSlotWidth),
)
val valuePlaceable = valueMeasurable.measure(
constraints.copy(minWidth = 0, maxWidth = valueSlotWidth),
)
val rowHeight = maxOf(titlePlaceable.height, valuePlaceable.height)
val yFor: (Int) -> Int = when (verticalAlignment) {
TangemRowVerticalAlignment.Top -> { _ -> 0 }
TangemRowVerticalAlignment.Center -> { h -> (rowHeight - h) / 2 }
}
layout(rowWidth, rowHeight) {
titlePlaceable.placeRelative(x = 0, y = yFor(titlePlaceable.height))
// Filling side is pinned to the row's trailing edge.
valuePlaceable.placeRelative(
x = rowWidth - valuePlaceable.width,
y = yFor(valuePlaceable.height),
)
}
}
}
@Composable
private fun LabelColumnContent(
primary: (@Composable RowScope.() -> Unit)?,
secondary: (@Composable RowScope.() -> Unit)?,
alignment: Alignment.Horizontal,
) {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens3.spacing.s025),
horizontalAlignment = alignment,
) {
if (primary != null) LabelRow(alignment = alignment, content = primary)
if (secondary != null) LabelRow(alignment = alignment, content = secondary)
}
}
@Composable
private fun LabelRow(alignment: Alignment.Horizontal, content: @Composable RowScope.() -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(
space = TangemTheme.dimens3.spacing.s050,
alignment = alignment,
),
content = content,
)
}
/** Semantic role of a label inside a [TangemRow], driving its typography, color and alignment. */
@Immutable
enum class TangemRowTextRole { Title, Subtitle, Value, Subvalue }
/**
* Default text styling for [TangemRow] label slots.
*
* Usage:
* ```
* TangemRowText(text = "Network fee", role = TangemRowTextRole.Title)
* ```
*
* @param text Label text.
* @param role Semantic role. See [TangemRowTextRole].
* @param modifier Modifier applied to the underlying [Text].
* @param maxLines Maximum number of visible lines before truncation.
* @param overflow Overflow behavior. Defaults to ellipsis.
*/
@Composable
fun TangemRowText(
text: String,
role: TangemRowTextRole,
modifier: Modifier = Modifier,
maxLines: Int = 1,
overflow: TextOverflow = TextOverflow.Ellipsis,
) {
Text(
text = text,
modifier = modifier,
color = rowTextColor(role),
style = rowTextStyle(role),
textAlign = rowTextAlign(role),
maxLines = maxLines,
overflow = overflow,
)
}
@Composable
private fun rowTextStyle(role: TangemRowTextRole): TextStyle = when (role) {
TangemRowTextRole.Title, TangemRowTextRole.Value -> TangemTheme.typography3.body.medium
TangemRowTextRole.Subtitle, TangemRowTextRole.Subvalue -> TangemTheme.typography3.caption.medium
}
@Composable
private fun rowTextColor(role: TangemRowTextRole): Color = when (role) {
TangemRowTextRole.Title, TangemRowTextRole.Value -> TangemTheme.colors3.text.primary
TangemRowTextRole.Subtitle, TangemRowTextRole.Subvalue -> TangemTheme.colors3.text.secondary
}
private fun rowTextAlign(role: TangemRowTextRole): TextAlign = when (role) {
TangemRowTextRole.Title, TangemRowTextRole.Subtitle -> TextAlign.Start
TangemRowTextRole.Value, TangemRowTextRole.Subvalue -> TextAlign.End
}
@Composable
private fun Modifier.focusBorder(): Modifier {
val radius = TangemTheme.dimens3.borderRadius.b200
val shape = remember(radius) { RoundedCornerShape(radius) }
return border(
width = TangemTheme.dimens3.borderWidth.md,
color = TangemTheme.colors3.interaction.focusRing.default,
shape = shape,
)
}
private fun Modifier.bottomDivider(color: Color, width: Dp, horizontalInset: Dp): Modifier = drawWithContent {
drawContent()
val stroke = width.toPx()
val inset = horizontalInset.toPx()
val y = size.height - stroke / 2f
drawLine(
color = color,
start = Offset(x = inset, y = y),
end = Offset(x = size.width - inset, y = y),
strokeWidth = stroke,
)
}
// region Previews
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemRowPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(vertical = 16.dp),
) {
// Equal lead, text only, with divider
TangemRow(
divider = true,
contentLead = TangemRowContentLead.Equal,
titleSlot = { TangemRowText(text = "Title", role = TangemRowTextRole.Title) },
subtitleSlot = { TangemRowText(text = "Subtitle", role = TangemRowTextRole.Subtitle) },
valueSlot = { TangemRowText(text = "Value", role = TangemRowTextRole.Value) },
subvalueSlot = { TangemRowText(text = "Subvalue", role = TangemRowTextRole.Subvalue) },
)
// Start lead — title hugs, value side fills
TangemRow(
divider = true,
contentLead = TangemRowContentLead.Start,
titleSlot = { TangemRowText(text = "Network fee", role = TangemRowTextRole.Title) },
valueSlot = { TangemRowText(text = "0.0001 ETH", role = TangemRowTextRole.Value) },
subvalueSlot = { TangemRowText(text = "$0.32", role = TangemRowTextRole.Subvalue) },
)
// End lead — value hugs, title side fills
TangemRow(
divider = true,
contentLead = TangemRowContentLead.End,
titleSlot = {
TangemRowText(
text = "Recipient with a very long address that should ellipsize",
role = TangemRowTextRole.Title,
)
},
valueSlot = { TangemRowText(text = "0x1234…abcd", role = TangemRowTextRole.Value) },
)
// Side slots + clickable + centered alignment
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
startSlot = {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
},
titleSlot = { TangemRowText(text = "Settings", role = TangemRowTextRole.Title) },
subtitleSlot = {
TangemRowText(text = "Tap to configure", role = TangemRowTextRole.Subtitle)
},
endSlot = {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
)
},
onClick = {},
)
// Title only — exercises nullable slots / single LabelRow path
TangemRow(
titleSlot = { TangemRowText(text = "Single-line row", role = TangemRowTextRole.Title) },
)
}
}
}
// endregion

View file

@ -17,8 +17,6 @@ import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.opportunities.OpportunitiesBG
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.*
@ -44,53 +42,52 @@ internal fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier:
@Composable
private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
OpportunitiesBG(
Column(
modifier = modifier
.width(178.dp)
.clip(RoundedCornerShape(TangemTheme.dimens2.x6))
.clickable(onClick = onClick),
icon = TangemIconUM.Currency(item.currencyIconState),
.background(TangemTheme.colors2.surface.level3)
.clickable(onClick = onClick)
.padding(12.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
CurrencyIcon(
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = TangemTheme.dimens2.x4,
iconSize = TangemTheme.dimens2.x10,
networkBadgeBackground = TangemTheme.colors.background.action,
)
CurrencyIcon(
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = TangemTheme.dimens2.x4,
iconSize = TangemTheme.dimens2.x10,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(22.dp)
Row(
verticalAlignment = Alignment.Bottom,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
)
}
SpacerH(2.dp)
SpacerH(22.dp)
Row(
verticalAlignment = Alignment.Bottom,
) {
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors2.text.status.positive,
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors2.text.status.positive,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
)
}
}

View file

@ -7,7 +7,10 @@ import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle
internal sealed interface StoryBookPage
@ -176,6 +179,55 @@ internal data class TangemButtonStory(
}
}
@Suppress("BooleanPropertyNaming")
internal data class TangemRowStory(
val contentLead: TangemRowContentLead,
val verticalAlignment: TangemRowVerticalAlignment,
val background: Background,
val divider: Boolean,
val includeInnerPaddings: Boolean,
val isClickable: Boolean,
val hasStartSlot: Boolean,
val hasEndSlot: Boolean,
val hasSubtitle: Boolean,
val hasValue: Boolean,
val hasSubvalue: Boolean,
val hasExtraBottom: Boolean,
val longTitle: Boolean,
val textScale: Float,
val onContentLeadChange: (TangemRowContentLead) -> Unit,
val onVerticalAlignmentChange: (TangemRowVerticalAlignment) -> Unit,
val onBackgroundChange: (Background) -> Unit,
val onDividerToggle: () -> Unit,
val onInnerPaddingsToggle: () -> Unit,
val onClickableToggle: () -> Unit,
val onStartSlotToggle: () -> Unit,
val onEndSlotToggle: () -> Unit,
val onSubtitleToggle: () -> Unit,
val onValueToggle: () -> Unit,
val onSubvalueToggle: () -> Unit,
val onExtraBottomToggle: () -> Unit,
val onLongTitleToggle: () -> Unit,
val onTextScaleChange: (Float) -> Unit,
) : DsStoryBookPage {
/** Backdrop the row preview is rendered on top of. */
enum class Background(val label: String) {
BgPrimary("bg.primary"),
BgSecondary("bg.secondary"),
BgBrand("bg.brand"),
BgInverse("bg.inverse"),
}
}
internal data class TangemFadeStory(
val variant: TangemFade.Variant,
val position: TangemFade.Position,
val isBlur: Boolean,
val onVariantChange: (TangemFade.Variant) -> Unit,
val onPositionChange: (TangemFade.Position) -> Unit,
val onBlurToggle: () -> Unit,
) : DsStoryBookPage
internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant,
val status: TangemBadge.Status,

View file

@ -17,7 +17,9 @@ import com.tangem.feature.tester.presentation.storybook.entity.DsComponentsListS
import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.badge.tangemBadgeV2StoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemButtonStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory
private data class DsStoryItem(val title: String, val factory: StoryPageFactory)
@ -26,7 +28,9 @@ private fun buildDsStories() = listOf(
DsStoryItem(title = "⏳ TangemLoader", factory = tangemLoaderStoryFactory),
DsStoryItem(title = "🔘 TangemButton", factory = tangemButtonStoryFactory),
DsStoryItem(title = "🏷️ TangemBadge", factory = tangemBadgeV2StoryFactory),
DsStoryItem(title = "📋 TangemRow", factory = tangemRowStoryFactory),
DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory),
DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory),
)
@Composable

View file

@ -18,7 +18,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
@ -54,13 +56,20 @@ internal fun TangemBadgeV2Story(state: TangemBadgeV2Story, modifier: Modifier =
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
StatusSelector(selected = state.status, onSelect = state.onStatusChange)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
StatusSelector(selected = state.status, onSelect = state.onStatusChange)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
}
}
}

View file

@ -50,12 +50,19 @@ internal fun TangemButtonStory(state: TangemButtonStory, modifier: Modifier = Mo
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
SizeSelector(selected = state.size, onSelect = state.onSizeChange)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
}
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.fade
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemFadeStory>.build(): TangemFadeStory {
return TangemFadeStory(
variant = TangemFade.Variant.Hard,
position = TangemFade.Position.Top,
isBlur = false,
onVariantChange = { variant ->
updateStory { it.copy(variant = variant) }
},
onPositionChange = { position ->
updateStory { it.copy(position = position) }
},
onBlurToggle = {
updateStory { it.copy(isBlur = !it.isBlur) }
},
)
}
internal val tangemFadeStoryFactory
get() = storyPageFactory(StateUpdater<TangemFadeStory>::build)

View file

@ -0,0 +1,245 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.fade
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory
@Composable
internal fun TangemFadeStory(state: TangemFadeStory, modifier: Modifier = Modifier) {
val hazeState = LocalHazeState.current
DisposableEffect(state.isBlur) {
val wasBlurEnabled = hazeState.blurEnabled
hazeState.blurEnabled = state.isBlur
onDispose { hazeState.blurEnabled = wasBlurEnabled }
}
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ComponentPreview(state = state)
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
PositionSelector(selected = state.position, onSelect = state.onPositionChange)
Toggles(state = state)
}
}
@Composable
private fun ComponentPreview(state: TangemFadeStory) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(240.dp)
.clip(RoundedCornerShape(16.dp)),
) {
Backdrop(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = 0f),
)
TangemFade(
position = state.position,
variant = state.variant,
blur = state.isBlur,
modifier = Modifier.align(
when (state.position) {
TangemFade.Position.Top -> Alignment.TopCenter
TangemFade.Position.Bottom -> Alignment.BottomCenter
},
),
)
}
}
@Composable
private fun Backdrop(modifier: Modifier = Modifier) {
val bands = remember {
listOf(
Color(0xFFFF1744),
Color(0xFFFF9100),
Color(0xFFFFEA00),
Color(0xFF00E676),
Color(0xFF00B8D4),
Color(0xFF2962FF),
Color(0xFFD500F9),
)
}
val stops = remember(bands) {
buildList {
bands.forEachIndexed { index, color ->
val start = index.toFloat() / bands.size
val end = (index + 1).toFloat() / bands.size
add(start to color)
add(end to color)
}
}.toTypedArray()
}
val tilePx = with(LocalDensity.current) { 160.dp.toPx() }
Box(
modifier = modifier.background(
brush = Brush.linearGradient(
colorStops = stops,
start = Offset(0f, 0f),
end = Offset(tilePx, tilePx),
tileMode = TileMode.Repeated,
),
),
)
}
@Composable
private fun VariantSelector(selected: TangemFade.Variant, onSelect: (TangemFade.Variant) -> Unit) {
Section(label = "Variant") {
ChipGrid(
items = TangemFade.Variant.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun PositionSelector(selected: TangemFade.Position, onSelect: (TangemFade.Position) -> Unit) {
Section(label = "Position") {
ChipGrid(
items = TangemFade.Position.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun Toggles(state: TangemFadeStory) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "blur", checked = state.isBlur, onToggle = state.onBlurToggle)
}
}
}
@Composable
private fun Section(label: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = label,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
content()
}
}
@Composable
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.secondary,
shape = shape,
)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEach { item ->
Chip(
label = label(item),
selected = isSelected(item),
onClick = { onSelect(item) },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val chipShape = RoundedCornerShape(50)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(chipShape)
.background(
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
Text(
text = label,
style = TangemTheme.typography.caption2,
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(TangemTheme.colors2.surface.level2)
.clickable(onClick = onToggle)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Text(
text = if (checked) "ON" else "OFF",
style = TangemTheme.typography.caption2,
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
)
}
}

View file

@ -0,0 +1,71 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.row
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemRowStory>.build(): TangemRowStory {
return TangemRowStory(
contentLead = TangemRowContentLead.Equal,
verticalAlignment = TangemRowVerticalAlignment.Top,
background = TangemRowStory.Background.BgPrimary,
divider = false,
includeInnerPaddings = true,
isClickable = false,
hasStartSlot = false,
hasEndSlot = false,
hasSubtitle = true,
hasValue = true,
hasSubvalue = true,
hasExtraBottom = false,
longTitle = false,
textScale = 1f,
onContentLeadChange = { contentLead ->
updateStory { it.copy(contentLead = contentLead) }
},
onVerticalAlignmentChange = { alignment ->
updateStory { it.copy(verticalAlignment = alignment) }
},
onBackgroundChange = { background ->
updateStory { it.copy(background = background) }
},
onDividerToggle = {
updateStory { it.copy(divider = !it.divider) }
},
onInnerPaddingsToggle = {
updateStory { it.copy(includeInnerPaddings = !it.includeInnerPaddings) }
},
onClickableToggle = {
updateStory { it.copy(isClickable = !it.isClickable) }
},
onStartSlotToggle = {
updateStory { it.copy(hasStartSlot = !it.hasStartSlot) }
},
onEndSlotToggle = {
updateStory { it.copy(hasEndSlot = !it.hasEndSlot) }
},
onSubtitleToggle = {
updateStory { it.copy(hasSubtitle = !it.hasSubtitle) }
},
onValueToggle = {
updateStory { it.copy(hasValue = !it.hasValue) }
},
onSubvalueToggle = {
updateStory { it.copy(hasSubvalue = !it.hasSubvalue) }
},
onExtraBottomToggle = {
updateStory { it.copy(hasExtraBottom = !it.hasExtraBottom) }
},
onLongTitleToggle = {
updateStory { it.copy(longTitle = !it.longTitle) }
},
onTextScaleChange = { scale ->
updateStory { it.copy(textScale = scale) }
},
)
}
internal val tangemRowStoryFactory
get() = storyPageFactory(StateUpdater<TangemRowStory>::build)

View file

@ -0,0 +1,369 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.row
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowText
import com.tangem.core.ui.ds2.row.TangemRowTextRole
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory.Background
private const val SHORT_TITLE = "Title"
private const val LONG_TITLE = "Recipient address that should ellipsize when constrained by width"
private const val SUBTITLE = "Subtitle"
private const val VALUE = "0.0421 ETH"
private const val SUBVALUE = "$124.80"
@Composable
internal fun TangemRowStory(state: TangemRowStory, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ContentLeadSelector(selected = state.contentLead, onSelect = state.onContentLeadChange)
VerticalAlignmentSelector(
selected = state.verticalAlignment,
onSelect = state.onVerticalAlignmentChange,
)
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
Toggles(state = state)
}
}
}
@Composable
private fun ComponentPreview(state: TangemRowStory) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp)),
) {
PreviewBackground(background = state.background, modifier = Modifier.matchParentSize())
val baseDensity = LocalDensity.current
val scaledDensity = remember(baseDensity, state.textScale) {
Density(density = baseDensity.density, fontScale = state.textScale)
}
CompositionLocalProvider(LocalDensity provides scaledDensity) {
Box(modifier = Modifier.padding(vertical = 24.dp)) {
StorybookRow(state = state)
}
}
}
}
@Composable
private fun StorybookRow(state: TangemRowStory) {
val titleText = if (state.longTitle) LONG_TITLE else SHORT_TITLE
TangemRow(
contentLead = state.contentLead,
verticalAlignment = state.verticalAlignment,
divider = state.divider,
includeInnerPaddings = state.includeInnerPaddings,
titleSlot = { TangemRowText(text = titleText, role = TangemRowTextRole.Title) },
subtitleSlot = if (state.hasSubtitle) {
{ TangemRowText(text = SUBTITLE, role = TangemRowTextRole.Subtitle) }
} else {
null
},
valueSlot = if (state.hasValue) {
{ TangemRowText(text = VALUE, role = TangemRowTextRole.Value) }
} else {
null
},
subvalueSlot = if (state.hasSubvalue) {
{ TangemRowText(text = SUBVALUE, role = TangemRowTextRole.Subvalue) }
} else {
null
},
startSlot = if (state.hasStartSlot) {
{
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
}
} else {
null
},
endSlot = if (state.hasEndSlot) {
{
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
)
}
} else {
null
},
extraBottomSlot = if (state.hasExtraBottom) {
{
Text(
text = "Extra bottom slot — multi-line description content.",
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
}
} else {
null
},
onClick = if (state.isClickable) {
{ /* no-op — ripple + focus demo */ }
} else {
null
},
)
}
@Composable
private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) {
when (background) {
Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary))
Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary))
Background.BgBrand -> Box(modifier.background(TangemTheme.colors3.bg.brand))
Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse))
}
}
@Composable
private fun ContentLeadSelector(selected: TangemRowContentLead, onSelect: (TangemRowContentLead) -> Unit) {
Section(label = "Content lead") {
ChipGrid(
items = TangemRowContentLead.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun VerticalAlignmentSelector(
selected: TangemRowVerticalAlignment,
onSelect: (TangemRowVerticalAlignment) -> Unit,
) {
Section(label = "Vertical alignment") {
ChipGrid(
items = TangemRowVerticalAlignment.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) {
Section(label = "Background") {
ChipGrid(
items = Background.entries,
label = { it.label },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun TextScaleSlider(value: Float, onChange: (Float) -> Unit) {
Section(label = "Text scale: ${"%.2f".format(value)}x") {
Slider(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
value = value,
onValueChange = onChange,
valueRange = 0.5f..2f,
steps = 14,
colors = SliderDefaults.colors(
thumbColor = TangemTheme.colors.text.accent,
activeTrackColor = TangemTheme.colors.text.accent,
activeTickColor = TangemTheme.colors2.surface.level3,
inactiveTrackColor = TangemTheme.colors2.surface.level3,
inactiveTickColor = TangemTheme.colors.text.accent,
),
)
}
}
@Composable
private fun Toggles(state: TangemRowStory) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "divider", checked = state.divider, onToggle = state.onDividerToggle)
ToggleRow(
label = "includeInnerPaddings",
checked = state.includeInnerPaddings,
onToggle = state.onInnerPaddingsToggle,
)
ToggleRow(
label = "clickable (ripple + focus)",
checked = state.isClickable,
onToggle = state.onClickableToggle,
)
ToggleRow(
label = "startSlot (leading icon)",
checked = state.hasStartSlot,
onToggle = state.onStartSlotToggle,
)
ToggleRow(
label = "endSlot (trailing icon)",
checked = state.hasEndSlot,
onToggle = state.onEndSlotToggle,
)
ToggleRow(label = "subtitle", checked = state.hasSubtitle, onToggle = state.onSubtitleToggle)
ToggleRow(label = "value", checked = state.hasValue, onToggle = state.onValueToggle)
ToggleRow(label = "subvalue", checked = state.hasSubvalue, onToggle = state.onSubvalueToggle)
ToggleRow(
label = "extraBottomSlot",
checked = state.hasExtraBottom,
onToggle = state.onExtraBottomToggle,
)
ToggleRow(
label = "long title (ellipsis test)",
checked = state.longTitle,
onToggle = state.onLongTitleToggle,
)
}
}
}
@Composable
private fun Section(label: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = label,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
content()
}
}
@Composable
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.secondary,
shape = shape,
)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEach { item ->
Chip(
label = label(item),
selected = isSelected(item),
onClick = { onSelect(item) },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val chipShape = RoundedCornerShape(50)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(chipShape)
.background(
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
Text(
text = label,
style = TangemTheme.typography.caption2,
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(TangemTheme.colors2.surface.level2)
.clickable(onClick = onToggle)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = if (checked) "ON" else "OFF",
style = TangemTheme.typography.caption2,
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
)
}
}

View file

@ -26,42 +26,48 @@ internal fun TangemShimmerStory(state: TangemShimmerStory, modifier: Modifier =
.statusBarsPadding()
.fillMaxSize()
.background(TangemTheme.colors3.bg.primary)
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Preview stays pinned at the top.
ComponentPreview(state = state)
ChipSection(label = "Text style") {
ChipGrid(
items = TextShimmerStyle.entries,
label = { it.chipLabel() },
isSelected = { it == state.textStyle },
onSelect = state.onTextStyleChange,
)
}
ChipSection(label = "Radius") {
ChipGrid(
items = RadiusOption.entries,
label = { it.label },
isSelected = { it == state.radius },
onSelect = state.onRadiusChange,
)
}
ChipSection(label = "Rectangle width") {
ChipGrid(
items = RectangleWidthOption.entries,
label = { it.label },
isSelected = { it == state.rectangleWidth },
onSelect = state.onRectangleWidthChange,
)
}
ChipSection(label = "Rectangle height") {
ChipGrid(
items = RectangleHeightOption.entries,
label = { it.label },
isSelected = { it == state.rectangleHeight },
onSelect = state.onRectangleHeightChange,
)
// Only the controls scroll.
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ChipSection(label = "Text style") {
ChipGrid(
items = TextShimmerStyle.entries,
label = { it.chipLabel() },
isSelected = { it == state.textStyle },
onSelect = state.onTextStyleChange,
)
}
ChipSection(label = "Radius") {
ChipGrid(
items = RadiusOption.entries,
label = { it.label },
isSelected = { it == state.radius },
onSelect = state.onRadiusChange,
)
}
ChipSection(label = "Rectangle width") {
ChipGrid(
items = RectangleWidthOption.entries,
label = { it.label },
isSelected = { it == state.rectangleWidth },
onSelect = state.onRectangleWidthChange,
)
}
ChipSection(label = "Rectangle height") {
ChipGrid(
items = RectangleHeightOption.entries,
label = { it.label },
isSelected = { it == state.rectangleHeight },
onSelect = state.onRectangleHeightChange,
)
}
}
}
}

View file

@ -4,6 +4,31 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.feature.tester.presentation.storybook.entity.ButtonsStory
import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory
import com.tangem.feature.tester.presentation.storybook.entity.DsComponentsListStory
import com.tangem.feature.tester.presentation.storybook.entity.NorthernLightsStory
import com.tangem.feature.tester.presentation.storybook.entity.OpportunitiesBGStory
import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory
import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeV2Story
import com.tangem.feature.tester.presentation.storybook.entity.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.entity.StoryBookUM
import com.tangem.feature.tester.presentation.storybook.entity.StoryList
import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemHeaderRowStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemLoaderStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemSegmentedPickerStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory
import com.tangem.feature.tester.presentation.storybook.entity.TypographyStory
import com.tangem.feature.tester.presentation.storybook.entity.*
import com.tangem.feature.tester.presentation.storybook.page.background.NorthernLightsStory
import com.tangem.feature.tester.presentation.storybook.page.badge.TangemBadgeStory
@ -14,7 +39,9 @@ import com.tangem.feature.tester.presentation.storybook.page.deviceicon.DeviceIc
import com.tangem.feature.tester.presentation.storybook.page.ds.DsComponentsListStory
import com.tangem.feature.tester.presentation.storybook.page.ds.badge.TangemBadgeV2Story
import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory
import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory
import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory
@ -63,7 +90,9 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
is TangemLoaderStory -> TangemLoaderStory(state = storyState)
is TangemButtonStory -> TangemButtonStory(state = storyState)
is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState)
is TangemRowStory -> TangemRowStory(state = storyState)
is TangemShimmerStory -> TangemShimmerStory(state = storyState)
is TangemFadeStory -> TangemFadeStory(state = storyState)
}
}
}

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

@ -2,27 +2,12 @@ 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
@ -31,31 +16,30 @@ import androidx.compose.ui.platform.LocalDensity
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.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.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 +48,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 +57,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 +72,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 +100,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)
}
}
@ -189,7 +164,14 @@ private fun TokenDetailsBody(
.padding(start = TangemTheme.dimens2.x4, end = TangemTheme.dimens2.x4, top = TangemTheme.dimens2.x4)
LazyColumn(
modifier = modifier,
modifier = modifier
.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 +183,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,
@ -218,12 +192,20 @@ private fun TokenDetailsBody(
item(key = "staking_block") {
EarnBlock(
state = earnBlock,
modifier = itemModifier,
modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x3),
)
}
}
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(

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,6 +13,7 @@ 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.*
@ -30,6 +33,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 +49,22 @@ 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(),
startContent = {
TangemTopBarActionContent(
modifier = actionModifier,
actionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_arrow_back_28,
onClick = topAppBarUM.onBackClick,
ghostModeProgress = 1f,
),
)
},
@ -65,10 +73,10 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier:
var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) }
Box {
TangemTopBarActionContent(
modifier = actionModifier,
actionUM = TangemTopBarActionUM(
iconRes = CoreUiR.drawable.ic_more_default_24,
onClick = { isDropdownMenuShown = true },
ghostModeProgress = 1f,
),
)
TangemDropdownMenu(
@ -100,8 +108,8 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier:
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 +146,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 +157,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 +177,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 +203,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 +211,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 +234,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 +271,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 +304,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 +336,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 +408,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 +431,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

@ -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

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.utils.disableButtons
import com.tangem.feature.wallet.presentation.wallet.state.utils.enableButtons
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
@ -34,11 +35,18 @@ internal class SetRefreshStateTransformer(
override fun transform(walletUM: WalletUM): WalletUM {
return when (walletUM) {
is WalletUM.Content -> walletUM.copy(
pullToRefreshConfig = walletUM.pullToRefreshConfig.toUpdatedState(isRefreshing),
tokensListUM = walletUM.tokensListUM.toUpdatedState(),
buttons = walletUM.enableButtons(),
)
is WalletUM.Content -> {
val tokensListUM = walletUM.tokensListUM.toUpdatedState()
walletUM.copy(
pullToRefreshConfig = walletUM.pullToRefreshConfig.toUpdatedState(isRefreshing),
tokensListUM = tokensListUM,
buttons = if (tokensListUM is WalletTokensListUM.Empty) {
walletUM.disableButtons()
} else {
walletUM.enableButtons()
},
)
}
is WalletUM.Locked -> walletUM
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.*
import com.tangem.feature.wallet.presentation.wallet.state.utils.disableButtons
import com.tangem.feature.wallet.presentation.wallet.state.utils.enableButtons
import com.tangem.features.tangempay.entity.TangemPayMainUM
import com.tangem.utils.logging.TangemLogger
@ -60,11 +61,16 @@ internal class SetTokenListTransformer(
override fun transform(walletUM: WalletUM): WalletUM {
return when (walletUM) {
is WalletUM.Content -> {
val tokensListUM = toLoadedState()
walletUM.copy(
walletsBalanceUM = walletUM.walletsBalanceUM.toLoadedState2(),
tangemPayMainUM = walletUM.tangemPayMainUM.toLoadedState(),
tokensListUM = toLoadedState(),
buttons = walletUM.enableButtons(),
tokensListUM = tokensListUM,
buttons = if (tokensListUM is WalletTokensListUM.Empty) {
walletUM.disableButtons()
} else {
walletUM.enableButtons()
},
)
}
is WalletUM.Locked -> {

View file

@ -59,8 +59,8 @@ import com.tangem.core.ui.components.sheetscaffold.*
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.extensions.softLayerShadow
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.softLayerShadow
import com.tangem.core.ui.res.*
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData
@ -176,6 +176,7 @@ private fun WalletContent2(
state.wallets2.getOrNull(state.selectedWalletIndex)?.pullToRefreshConfig,
)
}
var subtitleBottom by remember { mutableStateOf(0.dp) }
BaseScaffoldWithMarkets(
modifier = modifier,
@ -240,6 +241,7 @@ private fun WalletContent2(
pullToRefreshState = pullToRefreshState,
pullToRefreshConfig = pullToRefreshConfig,
behavior = behavior,
topOffset = subtitleBottom + TangemTheme.dimens2.x2,
)
val overlay = TangemTheme.colors2.overlay.overlayPrimary
@ -306,6 +308,9 @@ private fun WalletContent2(
walletBalanceUM = currentWallet.walletsBalanceUM,
buttons = currentWallet.buttons,
isBalanceHidden = state.isHidingMode,
onSubtitleBottomChange = { newValue ->
if (newValue > subtitleBottom) subtitleBottom = maxOf(subtitleBottom, newValue)
},
)
},
body = {

View file

@ -16,11 +16,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
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 androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer
@ -32,11 +36,7 @@ import com.tangem.core.ui.ds.placeholder.TextPlaceholder
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.MainScreenTestTags
@ -58,10 +58,12 @@ internal fun WalletBalance(
buttons: ImmutableList<TangemButtonUM>,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
onSubtitleBottomChange: (Dp) -> Unit = {},
) {
val collapsedFraction = behavior.state.collapsedFraction
val alpha = 1f - collapsedFraction
val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE)
val density = LocalDensity.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@ -85,7 +87,15 @@ internal fun WalletBalance(
isBalanceHidden = isBalanceHidden,
)
SpacerH(TangemTheme.dimens2.x3)
SubtitleRow(walletBalanceUM = walletBalanceUM)
Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
val rawBottomPx = coordinates.boundsInRoot().bottom
if (rawBottomPx <= 0f) return@onGloballyPositioned
onSubtitleBottomChange(with(density) { rawBottomPx.toDp() })
},
) {
SubtitleRow(walletBalanceUM = walletBalanceUM)
}
}
SpacerH(TangemTheme.dimens2.x2)
ActionButtons(buttons)

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.containers.pullToRefresh.getPullToRefreshIndicatorOffset
import com.tangem.core.ui.ds.TangemPagerIndicator
@ -23,7 +24,6 @@ import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
private const val MIN_SCALE = 0.75f
private const val MAX_SCALE = 1f
private const val WALLET_INDICATOR_OFFSET = 0.6f
@Composable
internal fun WalletPagerIndicator(
@ -31,6 +31,7 @@ internal fun WalletPagerIndicator(
behavior: TangemCollapsingAppBarBehavior,
pullToRefreshConfig: PullToRefreshConfig?,
pullToRefreshState: PullToRefreshState,
topOffset: Dp,
) {
val collapsedFraction = behavior.state.collapsedFraction
val alpha = MAX_SCALE - collapsedFraction
@ -43,7 +44,6 @@ internal fun WalletPagerIndicator(
pullToRefreshConfig = pullToRefreshConfig,
pullToRefreshState = pullToRefreshState,
)
val padding = height * WALLET_INDICATOR_OFFSET
AnimatedVisibility(
visible = pagerState.pageCount > 1,
@ -64,7 +64,7 @@ internal fun WalletPagerIndicator(
TangemPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.padding(top = padding)
.padding(top = topOffset)
.scale(scaleY = 1f, scaleX = scale),
)
}

View file

@ -0,0 +1,188 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.tokens.TokenConverterParams
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.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account.CryptoPortfolio.Companion.createMainAccount
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.features.tangempay.entity.TangemPayMainUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
import java.math.BigDecimal
class SetTokenListTransformerTest {
private val userWalletId = UserWalletId("00")
@Test
fun `GIVEN empty account list WHEN transform THEN tokens are Empty and buttons disabled`() {
val transformer = createTransformer(accountList = emptyAccountList())
val result = transformer.transform(walletUM(buttonEnabled = true)) as WalletUM.Content
assertThat(result.tokensListUM).isInstanceOf(WalletTokensListUM.Empty::class.java)
assertThat(result.buttons).hasSize(1)
assertThat(result.buttons.single().isEnabled).isFalse()
}
@Test
fun `GIVEN non-empty account list WHEN transform THEN tokens not Empty and buttons enabled`() {
val transformer = createTransformer(accountList = nonEmptyAccountList())
val result = transformer.transform(walletUM(buttonEnabled = false)) as WalletUM.Content
assertThat(result.tokensListUM).isNotInstanceOf(WalletTokensListUM.Empty::class.java)
assertThat(result.buttons).hasSize(1)
assertThat(result.buttons.single().isEnabled).isTrue()
}
private fun createTransformer(accountList: AccountStatusList): SetTokenListTransformer {
val userWallet = mockk<UserWallet.Hot>(relaxed = true) {
every { walletId } returns userWalletId
}
return SetTokenListTransformer(
params = TokenConverterParams.Account(
accountList = accountList,
expandedAccounts = emptySet(),
),
userWallet = userWallet,
appCurrency = AppCurrency.Default,
clickIntents = mockk<WalletClickIntents>(relaxed = true),
shouldShowMainPromo = false,
isAccountsModeEnabled = false,
isRedesignEnabled = true,
isAddAndManageTokensEnabled = false,
)
}
private fun emptyAccountList(): AccountStatusList = AccountStatusList(
userWalletId = userWalletId,
accountStatuses = listOf(mainCryptoPortfolioStatus(tokenList = TokenList.Empty)),
totalAccounts = 1,
totalArchivedAccounts = 0,
totalFiatBalance = TotalFiatBalance.Loading,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
private fun nonEmptyAccountList(): AccountStatusList {
val token = createToken()
val tokenList = TokenList.Ungrouped(
currencies = listOf(createLoadedStatus(token)),
totalFiatBalance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL),
sortedBy = TokensSortType.NONE,
)
return AccountStatusList(
userWalletId = userWalletId,
accountStatuses = listOf(mainCryptoPortfolioStatus(tokenList = tokenList)),
totalAccounts = 1,
totalArchivedAccounts = 0,
totalFiatBalance = TotalFiatBalance.Loading,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
}
private fun mainCryptoPortfolioStatus(tokenList: TokenList): AccountStatus.CryptoPortfolio =
AccountStatus.CryptoPortfolio(
account = createMainAccount(userWalletId),
tokenList = tokenList,
priceChangeLce = Unit.lceError(),
)
private fun walletUM(buttonEnabled: Boolean): WalletUM.Content = WalletUM.Content(
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
walletsBalanceUM = WalletBalanceUM.Loading(
id = userWalletId,
name = "test",
deviceIcon = DeviceIconUM.Mobile,
),
buttons = persistentListOf(
TangemButtonUM(
text = stringReference(value = "Buy"),
type = TangemButtonType.Secondary,
onClick = {},
isEnabled = buttonEnabled,
),
),
notifications = persistentListOf(),
notificationsCarousel = persistentListOf(),
tokensListUM = WalletTokensListUM.Loading,
nftState = WalletNFTItemUM.Hidden,
type = WalletType.Hot,
tangemPayMainUM = TangemPayMainUM.Empty,
)
private fun createToken(): CryptoCurrency.Token {
val network = Network(
id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None),
name = "ethereum",
currencySymbol = "ETH",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = false,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0xABCDEF"),
),
network = network,
name = "Token",
symbol = "TKN",
decimals = 18,
iconUrl = null,
isCustom = false,
contractAddress = "0xABCDEF",
)
}
private fun createLoadedStatus(token: CryptoCurrency.Token): CryptoCurrencyStatus {
val networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
value = "addr",
type = NetworkAddress.Address.Type.Primary,
),
)
val value = CryptoCurrencyStatus.Loaded(
amount = BigDecimal.ONE,
fiatAmount = BigDecimal.ZERO,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = networkAddress,
sources = CryptoCurrencyStatus.Sources(),
)
return CryptoCurrencyStatus(currency = token, value = value)
}
}

View file

@ -1,9 +1,42 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.lib.auth"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Core */
implementation(projects.core.utils)
implementation(projects.core.configToggles)
/** Tangem libraries */
implementation(tangemDeps.card.core)
/** Firebase */
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.crashlytics)
/** Other */
implementation(deps.arrow.core)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
}

View file

@ -0,0 +1,5 @@
package com.tangem.lib.auth
interface AuthFeatureToggles {
val isBackendAuthenticationEnabled: Boolean
}

View file

@ -0,0 +1,13 @@
package com.tangem.lib.auth
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import javax.inject.Inject
internal class DefaultAuthFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : AuthFeatureToggles {
override val isBackendAuthenticationEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15438_BACKEND_AUTHENTICATION_ENABLED)
}

View file

@ -0,0 +1,27 @@
package com.tangem.lib.auth.devicekey
import arrow.core.Option
/**
* Manages a device-bound secp256r1 keypair in Android Keystore (TEE/StrongBox).
* The private key never leaves the secure hardware.
*/
interface DeviceKeyManager {
/**
* Ensures the device keypair exists. Generates one if missing.
* Never throws generation failures are logged and reported via the return value.
* @return `true` if a new keypair was generated, `false` if it already existed or generation failed
*/
suspend fun generateIfMissing(): Boolean
/** Raw uncompressed public key (0x04 || x || y), or [arrow.core.None] if it cannot be read. */
suspend fun getPublicKey(): Option<ByteArray>
/**
* Signs [data] with SHA256withECDSA using the device private key.
* @return raw 64-byte signature (r || s), each component zero-padded to 32 bytes
* @throws DeviceKeySigningException if signing fails
*/
suspend fun sign(data: ByteArray): ByteArray
}

View file

@ -0,0 +1,3 @@
package com.tangem.lib.auth.devicekey
class DeviceKeySigningException(message: String, cause: Throwable? = null) : Exception(message, cause)

View file

@ -0,0 +1,38 @@
package com.tangem.lib.auth.devicekey.di
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager
import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.security.KeyStore
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object DeviceKeyModule {
@Provides
@Singleton
fun provideDeviceKeyManager(
authFeatureToggles: AuthFeatureToggles,
dispatchers: CoroutineDispatcherProvider,
): DeviceKeyManager {
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDeviceKeyManager
return runCatching {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
DefaultDeviceKeyManager(keyStore, dispatchers)
}.getOrElse { e ->
TangemLogger.e("Failed to init AndroidKeyStore, falling back to disabled DeviceKeyManager", e)
FirebaseCrashlytics.getInstance().recordException(e)
DisabledDeviceKeyManager
}
}
}

View file

@ -0,0 +1,125 @@
package com.tangem.lib.auth.devicekey.internal
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import arrow.core.None
import arrow.core.Option
import com.tangem.crypto.Secp256r1
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
internal class DefaultDeviceKeyManager(
private val keyStore: KeyStore,
private val dispatchers: CoroutineDispatcherProvider,
) : DeviceKeyManager {
override suspend fun generateIfMissing(): Boolean = withContext(dispatchers.io) {
if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false
try {
generateKey()
TangemLogger.i("Device key generated")
true
} catch (e: Exception) {
TangemLogger.e("Failed to generate device key", e)
false
}
}
override suspend fun getPublicKey(): Option<ByteArray> = withContext(dispatchers.io) {
Option.catch(
recover = { e ->
TangemLogger.e("Failed to get device public key", e)
None
},
f = ::getPublicKeyBytes,
)
}
override suspend fun sign(data: ByteArray): ByteArray = withContext(dispatchers.io) {
try {
val privateKey = keyStore.getKey(KEY_ALIAS, null)
?: throw DeviceKeySigningException("Device key not found")
val signature = Signature.getInstance(SIGNATURE_ALGORITHM).apply {
initSign(privateKey as java.security.PrivateKey)
update(data)
}
val derSignature = signature.sign()
Secp256r1.toByte64(derSignature)
} catch (e: DeviceKeySigningException) {
TangemLogger.e("Device key signing failed", e)
throw e
} catch (e: Exception) {
TangemLogger.e("Device key signing failed", e)
throw DeviceKeySigningException("Signing failed", e)
}
}
private fun generateKey() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
initAndGenerateKeyPair(strongBox = true)
} catch (e: Exception) {
TangemLogger.i("StrongBox unavailable, falling back to TEE", e)
initAndGenerateKeyPair(strongBox = false)
}
} else {
initAndGenerateKeyPair(strongBox = false)
}
}
private fun initAndGenerateKeyPair(strongBox: Boolean) {
val spec = buildKeyGenSpec(strongBox)
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER)
generator.initialize(spec)
generator.generateKeyPair()
}
private fun buildKeyGenSpec(strongBox: Boolean): KeyGenParameterSpec {
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(false)
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
return builder.build()
}
private fun getPublicKeyBytes(): ByteArray {
val cert = checkNotNull(keyStore.getCertificate(KEY_ALIAS)) { "Device key not found" }
val encoded = cert.publicKey.encoded
check(encoded.size >= EC_UNCOMPRESSED_POINT_SIZE) {
"Invalid encoded public key: expected at least $EC_UNCOMPRESSED_POINT_SIZE bytes, got ${encoded.size}"
}
val point = encoded.copyOfRange(encoded.size - EC_UNCOMPRESSED_POINT_SIZE, encoded.size)
check(point[0] == UNCOMPRESSED_POINT_PREFIX) {
"Invalid EC public key: expected uncompressed point prefix 0x04, got 0x${"%02x".format(point[0])}"
}
return point
}
private companion object {
const val KEYSTORE_PROVIDER = "AndroidKeyStore"
const val KEY_ALIAS = "tangem_device_key"
const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
const val EC_UNCOMPRESSED_POINT_SIZE = 65
const val UNCOMPRESSED_POINT_PREFIX = 0x04.toByte()
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.lib.auth.devicekey.internal
import arrow.core.None
import arrow.core.Option
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
internal object DisabledDeviceKeyManager : DeviceKeyManager {
override suspend fun generateIfMissing(): Boolean = false
override suspend fun getPublicKey(): Option<ByteArray> = None
override suspend fun sign(data: ByteArray): ByteArray =
throw DeviceKeySigningException("DeviceKeyManager is disabled: backend authentication feature toggle is off")
}

View file

@ -0,0 +1,21 @@
package com.tangem.lib.auth.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.DefaultAuthFeatureToggles
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 AuthFeatureTogglesModule {
@Provides
@Singleton
fun provideAuthFeatureToggles(featureTogglesManager: FeatureTogglesManager): AuthFeatureToggles {
return DefaultAuthFeatureToggles(featureTogglesManager)
}
}

View file

@ -0,0 +1,175 @@
package com.tangem.lib.auth.devicekey.internal
import arrow.core.None
import com.google.common.truth.Truth.assertThat
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.Certificate
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultDeviceKeyManagerTest {
private val keyStore: KeyStore = mockk(relaxed = true)
private val dispatchers = TestingCoroutineDispatcherProvider()
private val manager: DefaultDeviceKeyManager = DefaultDeviceKeyManager(keyStore, dispatchers)
@BeforeEach
fun setup() {
clearMocks(keyStore)
}
@AfterEach
fun teardown() {
unmockkAll()
}
@Test
fun `generateIfMissing returns false when key already exists`() = runTest {
every { keyStore.containsAlias(KEY_ALIAS) } returns true
val result = manager.generateIfMissing()
assertThat(result).isFalse()
}
@Test
fun `generateIfMissing returns false when generation fails`() = runTest {
every { keyStore.containsAlias(KEY_ALIAS) } returns false
val keyPairGenerator = mockk<KeyPairGenerator>(relaxed = true)
every { keyPairGenerator.generateKeyPair() } throws RuntimeException("keystore unavailable")
mockkStatic(KeyPairGenerator::class)
every { KeyPairGenerator.getInstance("EC", "AndroidKeyStore") } returns keyPairGenerator
val result = manager.generateIfMissing()
assertThat(result).isFalse()
}
@Test
fun `getPublicKey returns last 65 bytes from encoded key`() = runTest {
val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x04 }
val x509Header = ByteArray(26) { 0x30 }
val encoded = x509Header + rawPoint
val publicKey = mockk<java.security.PublicKey>()
every { publicKey.encoded } returns encoded
val cert = mockk<Certificate>()
every { cert.publicKey } returns publicKey
every { keyStore.getCertificate(KEY_ALIAS) } returns cert
val result = manager.getPublicKey()
assertThat(result.getOrNull()).isEqualTo(rawPoint)
}
@Test
fun `getPublicKey returns None when certificate not found`() = runTest {
every { keyStore.getCertificate(KEY_ALIAS) } returns null
val result = manager.getPublicKey()
assertThat(result).isEqualTo(None)
}
@Test
fun `getPublicKey returns None when point prefix is not uncompressed`() = runTest {
val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x02 }
val x509Header = ByteArray(26) { 0x30 }
val encoded = x509Header + rawPoint
val publicKey = mockk<java.security.PublicKey>()
every { publicKey.encoded } returns encoded
val cert = mockk<Certificate>()
every { cert.publicKey } returns publicKey
every { keyStore.getCertificate(KEY_ALIAS) } returns cert
val result = manager.getPublicKey()
assertThat(result).isEqualTo(None)
}
@Test
fun `sign returns raw 64-byte signature`() = runTest {
val data = "test data".toByteArray()
val r = ByteArray(32) { 0x01 }
val s = ByteArray(32) { 0x02 }
val derSignature = buildDer(r, s)
val privateKey = mockk<PrivateKey>()
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
val javaSig = mockk<java.security.Signature>()
every { javaSig.initSign(privateKey) } returns Unit
every { javaSig.update(data) } returns Unit
every { javaSig.sign() } returns derSignature
mockkSignatureGetInstance(javaSig)
val result = manager.sign(data)
assertThat(result).hasLength(64)
assertThat(result.copyOfRange(0, 32)).isEqualTo(r)
assertThat(result.copyOfRange(32, 64)).isEqualTo(s)
}
@Test
fun `sign throws DeviceKeySigningException when key not found`() = runTest {
every { keyStore.getKey(KEY_ALIAS, null) } returns null
val exception = assertThrows<DeviceKeySigningException> {
manager.sign("data".toByteArray())
}
assertThat(exception.message).contains("Device key not found")
}
@Test
fun `sign wraps unexpected exception in DeviceKeySigningException`() = runTest {
val privateKey = mockk<PrivateKey>()
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
val javaSig = mockk<java.security.Signature>()
every { javaSig.initSign(privateKey) } throws RuntimeException("hardware error")
mockkSignatureGetInstance(javaSig)
val exception = assertThrows<DeviceKeySigningException> {
manager.sign("data".toByteArray())
}
assertThat(exception.message).isEqualTo("Signing failed")
assertThat(exception.cause).isInstanceOf(RuntimeException::class.java)
}
private fun mockkSignatureGetInstance(mock: java.security.Signature) {
io.mockk.mockkStatic(java.security.Signature::class)
every { java.security.Signature.getInstance("SHA256withECDSA") } returns mock
}
private fun buildDer(r: ByteArray, s: ByteArray): ByteArray {
val rTlv = byteArrayOf(0x02, r.size.toByte()) + r
val sTlv = byteArrayOf(0x02, s.size.toByte()) + s
val body = rTlv + sTlv
return byteArrayOf(0x30, body.size.toByte()) + body
}
private companion object {
const val KEY_ALIAS = "tangem_device_key"
}
}