Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 12:24:53 +03:00
parent f74c37a080
commit 40c611efea
15 changed files with 954 additions and 58 deletions

View file

@ -21,6 +21,10 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val iconsDir: DirectoryProperty
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val assetsDir: DirectoryProperty
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val hashFile: RegularFileProperty
@ -48,14 +52,23 @@ abstract class VerifyDesignTokensTask : DefaultTask() {
"Run: git submodule update --init --recursive"
}
val assetsDirValue = assetsDir.get().asFile
require(assetsDirValue.exists() && assetsDirValue.isDirectory) {
"ds-tokens assets folder not found: ${assetsDirValue.absolutePath}\n" +
"Run: git submodule update --init --recursive"
}
val tokensInputHash = hashTreeHex(tokensDirValue, "json")
val iconsHash = hashTreeHex(iconsDirValue, "svg")
val assetsHash = hashTreeHex(assetsDirValue, "svg")
// Mirror build-tokens.mjs: sha256(tokensInputHash + 0x00 + iconsHash), all hex strings.
// Mirror build-tokens.mjs: sha256(tokensInputHash + 0x00 + iconsHash + 0x00 + assetsHash).
val outer = MessageDigest.getInstance("SHA-256")
outer.update(tokensInputHash.toByteArray())
outer.update(0)
outer.update(iconsHash.toByteArray())
outer.update(0)
outer.update(assetsHash.toByteArray())
val actual = outer.digest()
.joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') }
val expected = hashFileValue.readText().trim()
@ -116,6 +129,7 @@ android {
val verifyDesignTokens = tasks.register<VerifyDesignTokensTask>("verifyDesignTokens") {
tokensDir.set(file("ds-tokens/tokens"))
iconsDir.set(file("ds-tokens/icons"))
assetsDir.set(file("ds-tokens/assets"))
hashFile.set(file("src/main/java/com/tangem/core/ui/res/generated/.tokens-hash"))
stampFile.set(layout.buildDirectory.file("tokens-verified.stamp"))
}

@ -1 +1 @@
Subproject commit bfd2053b1f6d1ffc051d537e41e6bc386a34ad5f
Subproject commit a59c6a363903575d12ac7a6991ae76b60e064cd1

View file

@ -0,0 +1,427 @@
package com.tangem.core.ui.ds2.tokenicon
import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon.State.Indicator
import com.tangem.core.ui.extensions.ColorReference2
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.assets.il_token_custom
import com.tangem.core.ui.res.generated.assets.il_token_error
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
import com.tangem.core.ui.utils.getGreyScaleColorFilter
import kotlinx.coroutines.launch
/**
* Design-system v2 token icon rendered from a high-level [TangemTokenIcon.UiState] a loaded token,
* a loading shimmer, or an error placeholder.
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3901-470)
*
* @param state High-level rendering state; see [TangemTokenIcon.UiState].
* @param size One of the fixed [TangemTokenIcon.Size] presets driving the icon and overlay dimensions.
* @param modifier Modifier applied to the icon root. The icon is fixed-size per [size]; use this to
* position it within the parent.
* @param contentDescription Accessibility label describing the token this icon represents.
*/
@Composable
fun TangemTokenIcon(
state: TangemTokenIcon.UiState,
size: TangemTokenIcon.Size,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
when (state) {
TangemTokenIcon.UiState.Error -> {
Image(
modifier = modifier.size(size.tokens().size),
imageVector = Icons.il_token_error,
contentDescription = contentDescription,
)
}
is TangemTokenIcon.UiState.Token -> {
TangemTokenIcon(
state = state.tokenState,
size = size,
modifier = modifier,
contentDescription = contentDescription,
)
}
TangemTokenIcon.UiState.Shimmer -> {
TangemShimmer(radius = 999.dp, modifier = modifier.size(size.tokens().size))
}
}
}
/**
* Design-system v2 token icon rendered from a fully-resolved [TangemTokenIcon.State].
*
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3901-470)
*
* @param state Resolved icon state; see [TangemTokenIcon.State].
* @param size One of the fixed [TangemTokenIcon.Size] presets driving the icon and overlay dimensions.
* @param modifier Modifier applied to the icon root. The icon is fixed-size per [size]; use this to
* position it within the parent.
* @param contentDescription Accessibility label describing the token
*/
@Suppress("LongMethod")
@Composable
fun TangemTokenIcon(
state: TangemTokenIcon.State,
size: TangemTokenIcon.Size,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val (alpha, colorFilter) = remember(state.isGrayscale) {
getGreyScaleColorFilter(state.isGrayscale)
}
val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb()
val isDarkTheme = isSystemInDarkTheme()
val coroutineScope = rememberCoroutineScope()
val sizeTokens = size.tokens()
val pixelsSize = with(LocalDensity.current) { sizeTokens.size.roundToPx() }
val context = LocalContext.current
val iconUrl = state.url?.takeIf(String::isNotBlank)
val iconData: Any = iconUrl.orEmpty()
var iconBackgroundColor by remember(iconData) {
mutableStateOf(iconUrl?.let(contrastColorCache::get) ?: Color.Transparent)
}
var isBackgroundColorDefined by remember(iconData) {
mutableStateOf(iconUrl != null && contrastColorCache.get(iconUrl) != null)
}
val imageRequest = remember(iconData, pixelsSize, isDarkTheme, itemBackgroundColor) {
ImageRequest.Builder(context = context)
.data(iconData)
.size(size = pixelsSize)
.memoryCacheKey(key = iconData.toString() + pixelsSize)
.crossfade(enable = true)
.allowHardware(enable = !isDarkTheme) // Hardware bitmaps can't be read for Palette quantization.
.listener(
onSuccess = { _, result ->
if (!isBackgroundColorDefined && isDarkTheme && iconUrl != null) {
coroutineScope.launch {
val color = ImageBackgroundContrastChecker(
drawable = result.drawable,
backgroundColor = itemBackgroundColor,
size = pixelsSize,
).getContrastColor(isDarkTheme = true)
contrastColorCache.put(iconUrl, color)
iconBackgroundColor = color
isBackgroundColorDefined = true
}
}
},
).build()
}
Box(
modifier = modifier.size(sizeTokens.size),
) {
val hasCutout = state.topIcon != null || state.indicator != null
val baseModifier = Modifier
.matchParentSize()
.conditionalCompose(hasCutout) {
graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
val drawSize = this.size
drawContent()
if (state.topIcon != null) {
drawCircle(
color = Color.Transparent,
radius = (sizeTokens.topIconSize / 2 + CUTOUT).toPx(),
center = Offset(
x = drawSize.width -
(sizeTokens.topIconSize / 2 + sizeTokens.topIconOffset).toPx(),
y = (sizeTokens.topIconOffset + sizeTokens.topIconSize / 2).toPx(),
),
blendMode = BlendMode.Clear,
)
}
if (state.indicator != null) {
val indicatorCenter = drawSize.width -
(sizeTokens.indicatorOffset + sizeTokens.indicatorSize / 2).toPx()
drawCircle(
color = Color.Transparent,
radius = (sizeTokens.indicatorSize / 2 + CUTOUT).toPx(),
center = Offset(x = indicatorCenter, y = indicatorCenter),
blendMode = BlendMode.Clear,
)
}
}
}
.background(
color = iconBackgroundColor,
shape = RoundedCornerShape(8.dp),
)
.clip(RoundedCornerShape(8.dp))
if (state.url == null) {
Image(
modifier = baseModifier,
imageVector = Icons.il_token_custom,
contentDescription = contentDescription,
alpha = alpha,
colorFilter = colorFilter,
)
} else {
TokenAsyncImage(
imageRequest = imageRequest,
alpha = alpha,
colorFilter = colorFilter,
contentDescription = contentDescription,
modifier = baseModifier,
)
}
if (state.topIcon != null) {
Image(
modifier = Modifier
.offset(x = -sizeTokens.topIconOffset, y = sizeTokens.topIconOffset)
.align(Alignment.TopEnd)
.size(size = sizeTokens.topIconSize),
imageVector = state.topIcon,
contentDescription = null,
colorFilter = colorFilter,
alpha = alpha,
)
}
if (state.indicator != null) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = sizeTokens.indicatorOffset, bottom = sizeTokens.indicatorOffset)
.size(size = sizeTokens.indicatorSize)
.background(
color = state.indicator.colorReference2(),
shape = CircleShape,
)
.padding(1.dp),
)
}
}
}
@Composable
private fun TokenAsyncImage(
imageRequest: ImageRequest,
alpha: Float,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val painter = rememberAsyncImagePainter(model = imageRequest)
when (painter.state) {
is AsyncImagePainter.State.Success -> {
Image(
modifier = modifier,
painter = painter,
contentDescription = contentDescription,
alpha = alpha,
colorFilter = colorFilter,
)
}
is AsyncImagePainter.State.Error -> {
Image(
modifier = modifier,
imageVector = Icons.il_token_error,
contentDescription = contentDescription,
alpha = alpha,
colorFilter = colorFilter,
)
}
else -> {
TangemShimmer(radius = 999.dp, modifier = modifier)
}
}
}
/** Public API namespace for [TangemTokenIcon]. */
object TangemTokenIcon {
/** High-level rendering state for the [TangemTokenIcon] overload that accepts a [UiState]. */
@Immutable
sealed class UiState {
/** A resolved token icon, described by [tokenState]. */
data class Token(val tokenState: State) : UiState()
/** Loading placeholder — a circular shimmer. */
data object Shimmer : UiState()
/** Failure placeholder — the static error illustration. */
data object Error : UiState()
}
/**
* Fully-resolved token-icon state.
*
* @param url Remote image URL. `null` renders the custom-token illustration; blank is treated as `null`.
* @param topIcon Optional overlay icon drawn at the top-end corner (e.g. a network badge), with a
* transparent cutout punched behind it.
* @param isGrayscale When `true`, the whole icon is desaturated and dimmed.
* @param indicator Optional small status dot drawn at the bottom-end corner, with a cutout behind it.
*/
data class State(
val url: String?,
val topIcon: ImageVector? = null,
val isGrayscale: Boolean = false,
val indicator: Indicator? = null,
) {
/**
* Bottom-end status dot.
*
* @param colorReference2 Fill color of the dot, resolved from DS3 tokens.
*/
data class Indicator(
val colorReference2: ColorReference2 = ColorReference2 { TangemTheme.colors3.icon.tertiary },
)
}
/** Fixed icon size presets, in dp (`X40` = 40.dp … `X72` = 72.dp). */
enum class Size {
X40, X44, X56, X72
}
}
private fun TangemTokenIcon.Size.tokens(): Tokens {
return when (this) {
TangemTokenIcon.Size.X40 -> Tokens(
size = 40.dp,
topIconSize = 12.dp,
topIconOffset = (-1).dp,
indicatorSize = 6.dp,
indicatorOffset = 3.dp,
)
TangemTokenIcon.Size.X44 -> Tokens(
size = 44.dp,
topIconSize = 16.dp,
topIconOffset = (-2).dp,
indicatorSize = 6.dp,
indicatorOffset = 4.dp,
)
TangemTokenIcon.Size.X56 -> Tokens(
size = 56.dp,
topIconSize = 20.dp,
topIconOffset = (-4).dp,
indicatorSize = 6.dp,
indicatorOffset = 5.dp,
)
TangemTokenIcon.Size.X72 -> Tokens(
size = 72.dp,
topIconSize = 24.dp,
topIconOffset = (-4).dp,
indicatorSize = 8.dp,
indicatorOffset = 6.dp,
)
}
}
private class Tokens(
val size: Dp,
val topIconSize: Dp,
val topIconOffset: Dp,
val indicatorSize: Dp,
val indicatorOffset: Dp,
)
/** Transparent gap cut out of the base icon around the top icon and the indicator. */
private val CUTOUT = 1.dp
private const val CONTRAST_COLOR_CACHE_SIZE = 100
/**
* Caches the dark-theme contrast background color per icon URL so that revisiting an icon while
* scrolling reuses the result instead of re-decoding the bitmap and re-running Palette quantization.
*/
private val contrastColorCache = LruCache<String, Color>(CONTRAST_COLOR_CACHE_SIZE)
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
private fun TangemTokenIconPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PreviewRow(
state = TangemTokenIcon.State(url = ""),
)
PreviewRow(
state = TangemTokenIcon.State(
url = "",
indicator = Indicator(
colorReference2 = { TangemTheme.colors3.icon.brand },
),
topIcon = ImageVector.vectorResource(R.drawable.img_btc_cash_22),
),
)
PreviewRow(
state = TangemTokenIcon.State(url = null, isGrayscale = true),
)
}
}
}
@Composable
private fun PreviewRow(state: TangemTokenIcon.State, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
TangemTokenIcon.Size.entries.forEach { size ->
TangemTokenIcon(state = state, size = size)
}
}
}

View file

@ -1 +1 @@
2f248a061819a8d53c48378cf9d49908ed35abb58a0d75d79f0d98b2f4f97fc4
977ce9a11a9c9aae8b607f63f94e50983f3403d06dc552943fff74d91ff0d2ce

View file

@ -0,0 +1 @@
4127e6bef1d16ebcd2688aff883dbf08ede367ea90f6a3976dffa377c02b0062

View file

@ -0,0 +1,48 @@
@file:Suppress("all")
package com.tangem.core.ui.res.generated.assets
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.generated.icons.Icons
/**
* Auto-generated from design tokens. Do not edit manually.
*/
private var _il_token_custom: ImageVector? = null
val Icons.il_token_custom: ImageVector
get() {
if (_il_token_custom != null) return _il_token_custom!!
_il_token_custom = ImageVector.Builder(
name = "il_token_custom",
defaultWidth = 72.dp,
defaultHeight = 72.dp,
viewportWidth = 72f,
viewportHeight = 72f,
).apply {
addPath(
fill = SolidColor(Color(0xFF989898)),
pathFillType = PathFillType.NonZero,
pathData = addPathNodes("M36 16L37.7316 27.2942C38.2833 30.8928 41.1072 33.7167 44.7058 34.2684L56 36L44.7058 37.7316C41.1072 38.2833 38.2833 41.1072 37.7316 44.7058L36 56L34.2684 44.7058C33.7167 41.1072 30.8928 38.2833 27.2942 37.7316L16 36L27.2942 34.2684C30.8928 33.7167 33.7167 30.8928 34.2684 27.2942L36 16Z"),
)
}.build()
return _il_token_custom!!
}
@Composable
@Preview(showBackground = true)
private fun IlTokenCustomPreview() {
Icon(
imageVector = Icons.il_token_custom,
contentDescription = null,
)
}

View file

@ -0,0 +1,53 @@
@file:Suppress("all")
package com.tangem.core.ui.res.generated.assets
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.generated.icons.Icons
/**
* Auto-generated from design tokens. Do not edit manually.
*/
private var _il_token_error: ImageVector? = null
val Icons.il_token_error: ImageVector
get() {
if (_il_token_error != null) return _il_token_error!!
_il_token_error = ImageVector.Builder(
name = "il_token_error",
defaultWidth = 72.dp,
defaultHeight = 72.dp,
viewportWidth = 72f,
viewportHeight = 72f,
).apply {
addPath(
fill = SolidColor(Color(0xFF989898)),
pathFillType = PathFillType.NonZero,
pathData = addPathNodes("M35.9878 28.0059C36.1308 28.1328 36.2045 28.8048 36.2465 29.0355C36.489 30.3678 36.5924 31.7421 36.9527 33.0485C37.1171 33.645 37.5014 34.1627 37.9998 34.5013C38.824 35.1346 39.782 35.126 40.7125 35.3368C41.7693 35.5763 42.8366 35.6241 43.878 35.8204C42.8585 35.9143 41.7673 36.0899 40.7596 36.2732C40.176 36.3793 39.6277 36.4243 39.0557 36.6109C38.6851 36.7296 38.3339 36.9069 38.0151 37.1361C36.6891 38.076 36.7863 39.6009 36.491 41.0878C36.3871 41.6108 36.3229 42.1513 36.2334 42.6776C36.1768 43.0103 36.1778 43.3665 36.116 43.6962L36.1106 43.7247C35.9884 43.5741 35.9381 42.6806 35.9 42.4375C35.8158 41.8984 35.7348 41.3491 35.6369 40.8104C35.4512 39.8562 35.4291 38.6253 34.8496 37.8214C34.0091 36.6554 32.6573 36.4726 31.376 36.2644C30.3 36.0896 29.2284 35.9653 28.1589 35.7995C28.5816 35.7323 29.0428 35.7299 29.4714 35.6679C30.0937 35.58 30.7226 35.4601 31.3409 35.3511C32.5544 35.1322 33.7541 35.0925 34.6237 34.0518C35.3745 33.2323 35.3419 32.2197 35.559 31.2108C35.7972 30.1045 35.8495 29.1136 35.9878 28.0059Z"),
)
addPath(
fill = SolidColor(Color(0xFF989898)),
pathFillType = PathFillType.EvenOdd,
pathData = addPathNodes("M35.9998 18.1596C42.7132 15.1312 49.2667 15.1891 53.039 18.9612C56.8112 22.7333 56.8678 29.2859 53.8395 35.9991C56.8686 42.7129 56.8115 49.2668 53.039 53.0392L52.7638 53.3023C48.9535 56.8111 42.5535 56.7952 36.0009 53.8396C29.2871 56.8685 22.733 56.8115 18.9606 53.0392L18.6975 52.7629C15.1887 48.9527 15.2035 42.5526 18.1591 36.0002C15.1308 29.287 15.1896 22.7333 18.9617 18.9612C22.7339 15.1894 29.2868 15.1315 35.9998 18.1596ZM19.5045 38.6361C19.1888 39.4941 18.9313 40.3404 18.7336 41.1678C17.646 45.7219 18.4171 49.318 20.5494 51.4504C22.6819 53.5829 26.2788 54.3551 30.8332 53.2673C31.6604 53.0697 32.5061 52.811 33.3639 52.4953C30.667 50.958 27.9994 48.9357 25.5318 46.4681C23.0642 44.0005 21.0418 41.333 19.5045 38.6361ZM52.4952 38.635C50.9578 41.332 48.9366 44.0004 46.469 46.4681C44.0013 48.9357 41.3328 50.9568 38.6357 52.4943C39.4942 52.8101 40.3408 53.0685 41.1686 53.2662C45.7225 54.3537 49.3189 53.5836 51.4513 51.4515C53.5838 49.3191 54.3548 45.722 53.2671 41.1678C53.0694 40.3401 52.8111 39.4933 52.4952 38.635ZM35.9998 20.6431C32.9921 22.1337 29.9331 24.3053 27.1195 27.1188C24.306 29.9323 22.1345 32.9914 20.6437 35.9991C22.1343 39.007 24.3056 42.0665 27.1195 44.8804C29.933 47.6939 32.9921 49.8653 35.9998 51.3561C39.0076 49.8651 42.0674 47.6942 44.8813 44.8804C47.6952 42.0665 49.8662 39.0069 51.357 35.9991C49.866 32.9916 47.6937 29.9334 44.8802 27.1199C42.0663 24.3062 39.0076 22.134 35.9998 20.6431ZM51.4513 20.5488C49.3188 18.4165 45.7218 17.6464 41.1675 18.7342C40.3402 18.9318 39.4937 19.1883 38.6357 19.5039C41.3329 21.0413 44.0012 23.0634 46.469 25.5312C48.9366 27.9988 50.9578 30.6673 52.4952 33.3643C52.8109 32.5063 53.0695 31.66 53.2671 30.8326C54.3548 26.2784 53.5838 22.6812 51.4513 20.5488ZM30.8332 18.7331C26.2789 17.6453 22.6819 18.4175 20.5494 20.5499C18.4172 22.6824 17.647 26.2784 18.7347 30.8326C18.9322 31.6593 19.1891 32.5048 19.5045 33.3621C21.0417 30.6655 23.0645 27.9985 25.5318 25.5312C27.9994 23.0636 30.667 21.0412 33.3639 19.5039C32.5062 19.1884 31.6603 18.9307 30.8332 18.7331Z"),
)
}.build()
return _il_token_error!!
}
@Composable
@Preview(showBackground = true)
private fun IlTokenErrorPreview() {
Icon(
imageVector = Icons.il_token_error,
contentDescription = null,
)
}

View file

@ -23,10 +23,10 @@ Generates Kotlin (Jetpack Compose) source files from design tokens and icons def
```bash
cd core/ui/token-gen && npm run build
```
`npm run build` generates both tokens and icons. To regenerate only one of them:
`npm run build` generates tokens, icons and assets. To regenerate only one of them:
```bash
npm run build:tokens # node build-tokens.mjs
npm run build:icons # node build-icons.mjs
npm run build:tokens # node build-tokens.mjs (also runs icons + assets, folds their hashes into .tokens-hash)
npm run build:icons # node build-icons.mjs (generates both icons and assets)
```
3. Commit the generated files (and the submodule pointer too, only if you ran step 1).
@ -35,6 +35,12 @@ Generates Kotlin (Jetpack Compose) source files from design tokens and icons def
The build runs two scripts:
- **`build-tokens.mjs`** uses [Style Dictionary v5](https://styledictionary.com/) with [@tokens-studio/sd-transforms](https://github.com/tokens-studio/sd-transforms) to read JSON token files from `core/ui/ds-tokens/tokens/` and generate Kotlin files into `core/ui/src/main/java/com/tangem/core/ui/res/generated/`.
- **`build-icons.mjs`** generates Kotlin icon source files into `core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/`.
- **`build-icons.mjs`** generates Compose `ImageVector` Kotlin sources from two SVG folders:
- `core/ui/ds-tokens/icons/``…/res/generated/icons/` as extension properties on the `Icons` object.
Icons are single-color and tintable — the `#0F0F0F` placeholder is rewritten to `Color.Black` so
`Icon(tint = …)` can re-color them.
- `core/ui/ds-tokens/assets/``…/res/generated/assets/` as extension properties on the **same `Icons`
object** (imported from the icons package). Assets are illustrations that keep their own colors
verbatim (no tint placeholder), reachable at call sites as `Icons.il_token_custom` etc.
All generated files are written to `com.tangem.core.ui.res.generated` and should not be edited manually.

View file

@ -6,17 +6,51 @@ import { stripCr, compareCodeUnits } from './hash-util.mjs';
// ── Paths ──────────────────────────────────────────────────────────────────────
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const iconsDir = path.join(__dirname, '..', 'ds-tokens', 'icons');
const outputDir = path.join(
__dirname, '..', 'src', 'main', 'java', 'com', 'tangem', 'core', 'ui',
'res', 'generated', 'icons',
const dsTokensDir = path.join(__dirname, '..', 'ds-tokens');
const generatedDir = path.join(
__dirname, '..', 'src', 'main', 'java', 'com', 'tangem', 'core', 'ui', 'res', 'generated',
);
const PACKAGE = 'com.tangem.core.ui.res.generated.icons';
// ── Build configs ────────────────────────────────────────────────────────────────
// Two families of SVG vectors are generated the same way, only differing by:
// • source folder / output folder / package / namespace object
// • tint handling: icons are single-color and tintable (the #0F0F0F placeholder is
// rewritten to Color.Black so Icon(tint = …) can recolor them); illustrations
// ("assets") keep their own colors verbatim, so they have no tint placeholder.
// Source SVGs use #0F0F0F as a "tint placeholder" — rewrite to Color.Black so
// Icon(tint = …) at the call site can re-color the icon.
const TINT_PLACEHOLDERS = new Set(['#0f0f0f', '#0F0F0F']);
const ICONS_CONFIG = {
label: 'icon',
sourceDir: path.join(dsTokensDir, 'icons'),
outputDir: path.join(generatedDir, 'icons'),
packageName: 'com.tangem.core.ui.res.generated.icons',
namespace: 'Icons',
// Icons own the `Icons` namespace object, generated alongside them in this package.
generateNamespaceObject: true,
namespaceImport: null,
hashFileName: '.icons-hash',
namespaceDoc:
'Auto-generated namespace for design-system icons.\n' +
' * Each icon is provided as an extension property on this object.',
// Source SVGs use #0F0F0F as a "tint placeholder" — rewrite to Color.Black so
// Icon(tint = …) at the call site can re-color the icon.
tintPlaceholders: new Set(['#0f0f0f', '#0F0F0F']),
};
const ASSETS_CONFIG = {
label: 'asset',
sourceDir: path.join(dsTokensDir, 'assets'),
outputDir: path.join(generatedDir, 'assets'),
packageName: 'com.tangem.core.ui.res.generated.assets',
// Illustrations are exposed as `Icons.<name>` extensions (same namespace as icons),
// so they reuse the `Icons` object from the icons package — no separate object here.
namespace: 'Icons',
generateNamespaceObject: false,
namespaceImport: 'com.tangem.core.ui.res.generated.icons.Icons',
hashFileName: '.assets-hash',
namespaceDoc: null,
// Illustrations keep their own colors — no tint placeholder rewrite.
tintPlaceholders: new Set(),
};
// ── Helpers ────────────────────────────────────────────────────────────────────
@ -99,6 +133,8 @@ function parseSvg(filePath) {
/**
* ic_arrow_down_24_regular.svg
* { propName: 'ic_arrow_down_24', fileName: 'IcArrowDown24' }
* il_token_custom.svg
* { propName: 'il_token_custom', fileName: 'IlTokenCustom' }
*/
function deriveNames(svgFile) {
const base = path.basename(svgFile, '.svg').replace(/_regular$/, '');
@ -107,15 +143,15 @@ function deriveNames(svgFile) {
.map(part => capitalize(part))
.join('');
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(base)) {
throw new Error(`Icon name "${base}" is not a valid Kotlin identifier`);
throw new Error(`Vector name "${base}" is not a valid Kotlin identifier`);
}
return { propName: base, fileName };
}
/** Convert an SVG color string into a Compose Color expression, or null to skip. */
function svgColorToKotlin(value) {
function svgColorToKotlin(value, tintPlaceholders) {
if (!value || value === 'none') return null;
if (TINT_PLACEHOLDERS.has(value.toLowerCase())) return 'Color.Black';
if (tintPlaceholders.has(value.toLowerCase())) return 'Color.Black';
const hex6 = value.match(/^#([0-9a-fA-F]{6})$/);
if (hex6) return `Color(0xFF${hex6[1].toUpperCase()})`;
@ -145,7 +181,7 @@ function svgColorToKotlin(value) {
throw new Error(`Unsupported SVG color: "${value}"`);
}
function renderPath(p, indent) {
function renderPath(p, indent, config) {
const pad = ' '.repeat(indent);
const pad1 = ' '.repeat(indent + 1);
const args = [];
@ -153,7 +189,7 @@ function renderPath(p, indent) {
// If a path has no fill at all and has stroke, leave fill out. Otherwise default to tintable black.
const hasStroke = !!p.stroke && p.stroke !== 'none';
const fillSpecified = p.fill != null;
let fillKt = svgColorToKotlin(p.fill);
let fillKt = svgColorToKotlin(p.fill, config.tintPlaceholders);
if (!fillSpecified && !hasStroke) fillKt = 'Color.Black';
if (fillKt) args.push(`fill = SolidColor(${fillKt})`);
@ -163,7 +199,7 @@ function renderPath(p, indent) {
args.push(`fillAlpha = ${parseFloat(p.opacity)}f`);
}
const strokeKt = svgColorToKotlin(p.stroke);
const strokeKt = svgColorToKotlin(p.stroke, config.tintPlaceholders);
if (strokeKt) args.push(`stroke = SolidColor(${strokeKt})`);
if (p.strokeWidth != null) args.push(`strokeLineWidth = ${parseFloat(p.strokeWidth)}f`);
if (p.strokeLinecap) args.push(`strokeLineCap = StrokeCap.${capitalize(p.strokeLinecap)}`);
@ -178,7 +214,7 @@ function renderPath(p, indent) {
return lines.join('\n');
}
function renderIconFile({ propName, fileName }, icon) {
function renderIconFile({ propName, fileName }, icon, config) {
const usesStroke = icon.paths.some(p => p.stroke && p.stroke !== 'none');
const imports = [
@ -196,13 +232,15 @@ function renderIconFile({ propName, fileName }, icon) {
imports.push('androidx.compose.ui.graphics.StrokeCap');
imports.push('androidx.compose.ui.graphics.StrokeJoin');
}
// When the namespace object lives in another package, import it.
if (config.namespaceImport) imports.push(config.namespaceImport);
imports.sort();
const pathBlocks = icon.paths.map(p => renderPath(p, 2)).join('\n');
const pathBlocks = icon.paths.map(p => renderPath(p, 2, config)).join('\n');
return `@file:Suppress("all")
package ${PACKAGE}
package ${config.packageName}
${imports.map(i => `import ${i}`).join('\n')}
@ -212,7 +250,7 @@ ${imports.map(i => `import ${i}`).join('\n')}
private var _${propName}: ImageVector? = null
val Icons.${propName}: ImageVector
val ${config.namespace}.${propName}: ImageVector
get() {
if (_${propName} != null) return _${propName}!!
_${propName} = ImageVector.Builder(
@ -231,37 +269,38 @@ ${pathBlocks.replace(/^/gm, ' ')}
@Preview(showBackground = true)
private fun ${fileName}Preview() {
Icon(
imageVector = Icons.${propName},
imageVector = ${config.namespace}.${propName},
contentDescription = null,
)
}
`;
}
const ICONS_NAMESPACE = `@file:Suppress("all")
function renderNamespaceFile(config) {
return `@file:Suppress("all")
package ${PACKAGE}
package ${config.packageName}
/**
* Auto-generated namespace for design-system icons.
* Each icon is provided as an extension property on this object.
* ${config.namespaceDoc}
*/
object Icons
object ${config.namespace}
`;
}
// ── Hash gate ──────────────────────────────────────────────────────────────────
function computeIconsHash() {
const files = [...walkSvgs(iconsDir)];
function computeSourceHash(config) {
const files = [...walkSvgs(config.sourceDir)];
// Code-unit order (not localeCompare) to match the Kotlin verifier's invariantSeparatorsPath
// sort deterministically across locales/ICU versions — see hash-util.mjs.
files.sort((a, b) => compareCodeUnits(
path.relative(iconsDir, a).split(path.sep).join('/'),
path.relative(iconsDir, b).split(path.sep).join('/'),
path.relative(config.sourceDir, a).split(path.sep).join('/'),
path.relative(config.sourceDir, b).split(path.sep).join('/'),
));
const hash = crypto.createHash('sha256');
for (const file of files) {
hash.update(path.relative(iconsDir, file).split(path.sep).join('/'));
hash.update(path.relative(config.sourceDir, file).split(path.sep).join('/'));
hash.update('\0');
hash.update(stripCr(fs.readFileSync(file)));
hash.update('\0');
@ -269,32 +308,32 @@ function computeIconsHash() {
return hash.digest('hex');
}
// ── Main ───────────────────────────────────────────────────────────────────────
// ── Generic build ────────────────────────────────────────────────────────────────
export async function buildIcons() {
console.log('\nBuilding icon vectors...');
function buildVectors(config) {
console.log(`\nBuilding ${config.label} vectors...`);
const newHash = computeIconsHash();
const hashFile = path.join(outputDir, '.icons-hash');
const newHash = computeSourceHash(config);
const hashFile = path.join(config.outputDir, config.hashFileName);
if (fs.existsSync(hashFile)) {
const prev = fs.readFileSync(hashFile, 'utf8').trim();
if (prev === newHash) {
console.log(`icons unchanged (${newHash.substring(0, 12)}…); skipping`);
console.log(`${config.label}s unchanged (${newHash.substring(0, 12)}…); skipping`);
return { hash: newHash };
}
}
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(config.outputDir, { recursive: true });
// Parse every SVG up-front so we fail fast on errors before writing anything.
const icons = [];
for (const svgFile of walkSvgs(iconsDir)) {
for (const svgFile of walkSvgs(config.sourceDir)) {
const names = deriveNames(svgFile);
let parsed;
try {
parsed = parseSvg(svgFile);
} catch (e) {
throw new Error(`${path.relative(iconsDir, svgFile)}: ${e.message}`);
throw new Error(`${path.relative(config.sourceDir, svgFile)}: ${e.message}`);
}
icons.push({ names, parsed });
}
@ -304,39 +343,54 @@ export async function buildIcons() {
for (const { names } of icons) {
if (seen.has(names.propName)) {
throw new Error(
`Duplicate icon property "${names.propName}" (file collision: ` +
`Duplicate ${config.label} property "${names.propName}" (file collision: ` +
`${seen.get(names.propName)}.kt vs ${names.fileName}.kt)`,
);
}
seen.set(names.propName, names.fileName);
}
// Write namespace + per-icon files.
const expectedFiles = new Set(['Icons.kt', '.icons-hash']);
fs.writeFileSync(path.join(outputDir, 'Icons.kt'), ICONS_NAMESPACE);
// Write namespace (only if this family owns the object) + per-vector files.
const expectedFiles = new Set([config.hashFileName]);
if (config.generateNamespaceObject) {
const namespaceFile = `${config.namespace}.kt`;
expectedFiles.add(namespaceFile);
fs.writeFileSync(path.join(config.outputDir, namespaceFile), renderNamespaceFile(config));
}
for (const { names, parsed } of icons) {
const file = `${names.fileName}.kt`;
expectedFiles.add(file);
fs.writeFileSync(path.join(outputDir, file), renderIconFile(names, parsed));
fs.writeFileSync(path.join(config.outputDir, file), renderIconFile(names, parsed, config));
}
// Cleanup stale generated files (icons that no longer have a source SVG).
// Cleanup stale generated files (vectors that no longer have a source SVG).
let removed = 0;
for (const entry of fs.readdirSync(outputDir)) {
for (const entry of fs.readdirSync(config.outputDir)) {
if (!expectedFiles.has(entry) && entry.endsWith('.kt')) {
fs.unlinkSync(path.join(outputDir, entry));
fs.unlinkSync(path.join(config.outputDir, entry));
removed++;
}
}
fs.writeFileSync(hashFile, newHash + '\n');
const removedNote = removed > 0 ? `, removed ${removed} stale` : '';
console.log(`${icons.length} icon(s) (${newHash.substring(0, 12)}${removedNote})`);
console.log(`${icons.length} ${config.label}(s) (${newHash.substring(0, 12)}${removedNote})`);
return { hash: newHash };
}
// ── Public API ───────────────────────────────────────────────────────────────────
export async function buildIcons() {
return buildVectors(ICONS_CONFIG);
}
export async function buildAssets() {
return buildVectors(ASSETS_CONFIG);
}
// Run directly when executed as `node build-icons.mjs`.
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
await buildIcons();
await buildAssets();
}

View file

@ -4,7 +4,7 @@ import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { buildIcons } from './build-icons.mjs';
import { buildIcons, buildAssets } from './build-icons.mjs';
import { stripCr, compareCodeUnits } from './hash-util.mjs';
// ── Paths ──────────────────────────────────────────────────────────────────────
@ -810,10 +810,11 @@ function computeTokensHash() {
return hash.digest('hex');
}
// ── Build icons ───────────────────────────────────────────────────────────────
// Run before writing .tokens-hash so the icons hash can be folded in — Gradle
// then has a single hash that invalidates on any ds-tokens change (tokens or icons).
// ── Build icons & assets ────────────────────────────────────────────────────────
// Run before writing .tokens-hash so both SVG hashes can be folded in — Gradle then
// has a single hash that invalidates on any ds-tokens change (tokens, icons or assets).
const { hash: iconsHash } = await buildIcons();
const { hash: assetsHash } = await buildAssets();
const tokensInputHash = computeTokensHash();
const tokensHash = crypto
@ -821,6 +822,8 @@ const tokensHash = crypto
.update(tokensInputHash)
.update('\0')
.update(iconsHash)
.update('\0')
.update(assetsHash)
.digest('hex');
fs.writeFileSync(path.join(outputDir, '.tokens-hash'), tokensHash + '\n');
console.log(`\n ✓ .tokens-hash (${tokensHash.substring(0, 12)}…)`);