From 40c611efeaad13b0318ceb2d86318a17fcae4540 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 12:24:53 +0300 Subject: [PATCH] Updated on 2026-08-14 --- core/ui/build.gradle.kts | 16 +- core/ui/ds-tokens | 2 +- .../core/ui/ds2/tokenicon/TangemTokenIcon.kt | 427 ++++++++++++++++++ .../tangem/core/ui/res/generated/.tokens-hash | 2 +- .../core/ui/res/generated/assets/.assets-hash | 1 + .../ui/res/generated/assets/IlTokenCustom.kt | 48 ++ .../ui/res/generated/assets/IlTokenError.kt | 53 +++ core/ui/token-gen/README.md | 14 +- core/ui/token-gen/build-icons.mjs | 148 ++++-- core/ui/token-gen/build-tokens.mjs | 11 +- .../storybook/entity/StoryBookPage.kt | 22 + .../page/ds/DsComponentsListScreen.kt | 2 + .../storybook/page/ds/tokenicon/Build.kt | 28 ++ .../page/ds/tokenicon/TangemTokenIconStory.kt | 236 ++++++++++ .../storybook/ui/StoryBookScreen.kt | 2 + 15 files changed, 954 insertions(+), 58 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/tokenicon/TangemTokenIcon.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/.assets-hash create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/IlTokenCustom.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/IlTokenError.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenicon/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenicon/TangemTokenIconStory.kt diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 5863329189..6a07fb98c3 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -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("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")) } diff --git a/core/ui/ds-tokens b/core/ui/ds-tokens index bfd2053b1f..a59c6a3639 160000 --- a/core/ui/ds-tokens +++ b/core/ui/ds-tokens @@ -1 +1 @@ -Subproject commit bfd2053b1f6d1ffc051d537e41e6bc386a34ad5f +Subproject commit a59c6a363903575d12ac7a6991ae76b60e064cd1 diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenicon/TangemTokenIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenicon/TangemTokenIcon.kt new file mode 100644 index 0000000000..eca3f377f7 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/tokenicon/TangemTokenIcon.kt @@ -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(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) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash index 2267a0d8d6..7afbd47526 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash @@ -1 +1 @@ -2f248a061819a8d53c48378cf9d49908ed35abb58a0d75d79f0d98b2f4f97fc4 +977ce9a11a9c9aae8b607f63f94e50983f3403d06dc552943fff74d91ff0d2ce diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/.assets-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/.assets-hash new file mode 100644 index 0000000000..d5b588a15e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/.assets-hash @@ -0,0 +1 @@ +4127e6bef1d16ebcd2688aff883dbf08ede367ea90f6a3976dffa377c02b0062 diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/IlTokenCustom.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/IlTokenCustom.kt new file mode 100644 index 0000000000..669a68f987 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/IlTokenCustom.kt @@ -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, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/IlTokenError.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/IlTokenError.kt new file mode 100644 index 0000000000..5c176080df --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/assets/IlTokenError.kt @@ -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, + ) +} \ No newline at end of file diff --git a/core/ui/token-gen/README.md b/core/ui/token-gen/README.md index e170af92ef..be4510ea1b 100644 --- a/core/ui/token-gen/README.md +++ b/core/ui/token-gen/README.md @@ -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. diff --git a/core/ui/token-gen/build-icons.mjs b/core/ui/token-gen/build-icons.mjs index 3a8e28405f..655e9a75c5 100644 --- a/core/ui/token-gen/build-icons.mjs +++ b/core/ui/token-gen/build-icons.mjs @@ -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.` 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(); } diff --git a/core/ui/token-gen/build-tokens.mjs b/core/ui/token-gen/build-tokens.mjs index 6f7c18527b..7a2ac174b6 100644 --- a/core/ui/token-gen/build-tokens.mjs +++ b/core/ui/token-gen/build-tokens.mjs @@ -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)}…)`); diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index 4d2f33c8fc..12076f46e3 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -14,6 +14,7 @@ import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation internal sealed interface StoryBookPage @@ -353,6 +354,27 @@ internal data class TangemCheckmarkStory( val onEnabledToggle: () -> Unit, ) : DsStoryBookPage +internal data class TangemTokenIconStory( + val uiState: UiStateVariant, + val size: TangemTokenIcon.Size, + val hasUrl: Boolean, + val isGrayscale: Boolean, + val hasIndicator: Boolean, + val isIndicatorPurple: Boolean, + val hasTopIcon: Boolean, + val onUiStateChange: (UiStateVariant) -> Unit, + val onSizeChange: (TangemTokenIcon.Size) -> Unit, + val onUrlToggle: () -> Unit, + val onGrayscaleToggle: () -> Unit, + val onIndicatorToggle: () -> Unit, + val onIndicatorPurpleToggle: () -> Unit, + val onTopIconToggle: () -> Unit, +) : DsStoryBookPage { + + /** Selects which [TangemTokenIcon.UiState] the high-level overload is rendered with. */ + enum class UiStateVariant { Token, Shimmer, Error } +} + internal data class TangemGlowRingStory( val variant: TangemGlowRing.Variant, val quality: TangemGlowRing.Quality, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 88d7b8883f..3efeb305ae 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -27,6 +27,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowSto import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.textStyleStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.ds.tokenicon.tangemTokenIconStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.tangemTopNavigationStoryFactory private data class DsStoryItem(val title: String, val factory: StoryPageFactory) @@ -37,6 +38,7 @@ private fun buildDsStories() = listOf( DsStoryItem(title = "🏷️ TangemBadge", factory = tangemBadgeV2StoryFactory), DsStoryItem(title = "☑️ TangemCheckbox", factory = tangemCheckboxV2StoryFactory), DsStoryItem(title = "⭕ TangemCheckmark", factory = tangemCheckmarkStoryFactory), + DsStoryItem(title = "🎨 TangemTokenIcon", factory = tangemTokenIconStoryFactory), DsStoryItem(title = "📋 TangemRow", factory = tangemRowStoryFactory), DsStoryItem(title = "🔎 TangemSearch", factory = tangemSearchStoryFactory), DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenicon/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenicon/Build.kt new file mode 100644 index 0000000000..efa6acf729 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenicon/Build.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.tester.presentation.storybook.page.ds.tokenicon + +import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon +import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenIconStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemTokenIconStory { + return TangemTokenIconStory( + uiState = TangemTokenIconStory.UiStateVariant.Token, + size = TangemTokenIcon.Size.X56, + hasUrl = true, + isGrayscale = false, + hasIndicator = false, + isIndicatorPurple = false, + hasTopIcon = false, + onUiStateChange = { uiState -> updateStory { it.copy(uiState = uiState) } }, + onSizeChange = { size -> updateStory { it.copy(size = size) } }, + onUrlToggle = { updateStory { it.copy(hasUrl = !it.hasUrl) } }, + onGrayscaleToggle = { updateStory { it.copy(isGrayscale = !it.isGrayscale) } }, + onIndicatorToggle = { updateStory { it.copy(hasIndicator = !it.hasIndicator) } }, + onIndicatorPurpleToggle = { updateStory { it.copy(isIndicatorPurple = !it.isIndicatorPurple) } }, + onTopIconToggle = { updateStory { it.copy(hasTopIcon = !it.hasTopIcon) } }, + ) +} + +internal val tangemTokenIconStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenicon/TangemTokenIconStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenicon/TangemTokenIconStory.kt new file mode 100644 index 0000000000..01da2da235 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/tokenicon/TangemTokenIconStory.kt @@ -0,0 +1,236 @@ +@file:Suppress("MagicNumber") + +package com.tangem.feature.tester.presentation.storybook.page.ds.tokenicon + +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds2.tokenicon.TangemTokenIcon +import com.tangem.core.ui.extensions.ColorReference2 +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenIconStory + +private const val SAMPLE_URL = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/bitcoin.png" + +@Composable +internal fun TangemTokenIconStory(state: TangemTokenIconStory, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors3.bg.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), + ) { + UiStateSelector(selected = state.uiState, onSelect = state.onUiStateChange) + SizeSelector(selected = state.size, onSelect = state.onSizeChange) + // The State flags only apply to the Token variant of the UiState overload. + if (state.uiState == TangemTokenIconStory.UiStateVariant.Token) { + Toggles(state = state) + } + } + } +} + +@Composable +private fun ComponentPreview(state: TangemTokenIconStory) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors3.bg.secondary) + .padding(vertical = 48.dp), + ) { + // Rendered through the high-level UiState overload. + val uiState = when (state.uiState) { + TangemTokenIconStory.UiStateVariant.Token -> TangemTokenIcon.UiState.Token( + tokenState = TangemTokenIcon.State( + url = if (state.hasUrl) SAMPLE_URL else null, + topIcon = if (state.hasTopIcon) ImageVector.vectorResource(R.drawable.img_bsc_22) else null, + isGrayscale = state.isGrayscale, + indicator = when { + !state.hasIndicator -> null + state.isIndicatorPurple -> TangemTokenIcon.State.Indicator( + colorReference2 = ColorReference2 { TangemTheme.colors3.icon.accent.violet }, + ) + else -> TangemTokenIcon.State.Indicator() + }, + ), + ) + TangemTokenIconStory.UiStateVariant.Shimmer -> TangemTokenIcon.UiState.Shimmer + TangemTokenIconStory.UiStateVariant.Error -> TangemTokenIcon.UiState.Error + } + TangemTokenIcon(state = uiState, size = state.size) + } +} + +@Composable +private fun UiStateSelector( + selected: TangemTokenIconStory.UiStateVariant, + onSelect: (TangemTokenIconStory.UiStateVariant) -> Unit, +) { + Section(label = "UiState") { + 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), + ) { + TangemTokenIconStory.UiStateVariant.entries.forEach { variant -> + Chip( + label = variant.name, + selected = variant == selected, + onClick = { onSelect(variant) }, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun SizeSelector(selected: TangemTokenIcon.Size, onSelect: (TangemTokenIcon.Size) -> Unit) { + Section(label = "Size") { + 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), + ) { + TangemTokenIcon.Size.entries.forEach { size -> + Chip( + label = size.name, + selected = size == selected, + onClick = { onSelect(size) }, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun Toggles(state: TangemTokenIconStory) { + Section(label = "Flags") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToggleRow(label = "hasUrl (else fallback)", checked = state.hasUrl, onToggle = state.onUrlToggle) + ToggleRow(label = "isGrayscale", checked = state.isGrayscale, onToggle = state.onGrayscaleToggle) + ToggleRow(label = "hasIndicator", checked = state.hasIndicator, onToggle = state.onIndicatorToggle) + ToggleRow( + label = "indicator purple (else default)", + checked = state.isIndicatorPurple, + onToggle = state.onIndicatorPurpleToggle, + ) + ToggleRow(label = "hasTopIcon", checked = state.hasTopIcon, onToggle = state.onTopIconToggle) + } + } +} + +@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 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, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index 721a0b4433..fbfc2eab43 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -49,6 +49,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowSto import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory import com.tangem.feature.tester.presentation.storybook.page.ds.textstyle.TextStyleStory +import com.tangem.feature.tester.presentation.storybook.page.ds.tokenicon.TangemTokenIconStory import com.tangem.feature.tester.presentation.storybook.page.ds.topnavigation.TangemTopNavigationStory import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory @@ -99,6 +100,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState) is TangemCheckboxV2Story -> TangemCheckboxV2Story(state = storyState) is TangemCheckmarkStory -> TangemCheckmarkStory(state = storyState) + is TangemTokenIconStory -> TangemTokenIconStory(state = storyState) is TangemGlowRingStory -> TangemGlowRingStory(state = storyState) is TangemRowStory -> TangemRowStory(state = storyState) is TangemSearchStory -> TangemSearchStory(state = storyState)