diff --git a/.mcp.json b/.mcp.json index 009df155b7..3721a9071a 100644 --- a/.mcp.json +++ b/.mcp.json @@ -9,6 +9,11 @@ "type": "stdio", "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"] + }, + "notion": { + "type": "stdio", + "command": "npx", + "args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"] } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 4c7aa189cb..88b77270c2 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -5,6 +5,7 @@ import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager +import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.config.environment.EnvironmentConfig @@ -49,4 +50,6 @@ interface ApplicationEntryPoint { fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor + + fun getDeviceKeyManager(): DeviceKeyManager } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 3684c3af20..cca253b4cc 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -21,6 +21,7 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.common.LogConfig import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler @@ -92,6 +93,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val sendTransactionSignerInfoInterceptor get() = entryPoint.getSendTransactionSignerInfoInterceptor() + private val deviceKeyManager: DeviceKeyManager + get() = entryPoint.getDeviceKeyManager() + // endregion private val appScope = MainScope() @@ -132,6 +136,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. } fun init() { + appScope.launch { + deviceKeyManager.generateIfMissing() + } walletsRepository = entryPoint.getWalletsRepository() apiConfigsManager.initialize() diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt index be6c477557..da370bebe6 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -4,13 +4,7 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text @@ -36,20 +30,12 @@ import com.tangem.common.ui.earn.EarnBlockUM.Type import com.tangem.core.ui.R import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.ds.button.TangemButton -import com.tangem.core.ui.ds.button.TangemButtonShape -import com.tangem.core.ui.ds.button.TangemButtonSize -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.TangemRowContainer import com.tangem.core.ui.ds.row.TangemRowLayoutId -import com.tangem.core.ui.extensions.resolveAnnotatedReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.res.R as CoreResR @@ -74,7 +60,7 @@ fun EarnBlock(state: EarnBlockUM, modifier: Modifier = Modifier) { @Composable private fun EarnBlockLoading(modifier: Modifier = Modifier) { - val shape = RoundedCornerShape(TangemTheme.dimens2.x4) + val shape = RoundedCornerShape(TangemTheme.dimens2.x5) TangemRowContainer( modifier = modifier .clip(shape) @@ -91,13 +77,13 @@ private fun EarnBlockLoading(modifier: Modifier = Modifier) { RectangleShimmer( modifier = Modifier .layoutId(TangemRowLayoutId.START_TOP) - .size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5), + .size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4), radius = TangemTheme.dimens2.x2, ) RectangleShimmer( modifier = Modifier .layoutId(TangemRowLayoutId.START_BOTTOM) - .size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4), + .size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5), radius = TangemTheme.dimens2.x2, ) }, @@ -106,7 +92,7 @@ private fun EarnBlockLoading(modifier: Modifier = Modifier) { @Composable private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) { - val shape = RoundedCornerShape(TangemTheme.dimens2.x4) + val shape = RoundedCornerShape(TangemTheme.dimens2.x5) val clickModifier = state.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier @@ -114,7 +100,7 @@ private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Mo modifier = modifier .clip(shape) .then(clickModifier.backgroundModifier(state.type, state.backgroundUM, shape)), - contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), + contentPadding = PaddingValues(all = TangemTheme.dimens2.x4), content = { EarnBlockIcon( type = state.type, @@ -361,7 +347,7 @@ private val EarnBlockUM.TitleUM.Style.textStyle: TextStyle @Composable @ReadOnlyComposable get() = when (this) { - EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodyMedium16 EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 } @@ -369,7 +355,7 @@ private val EarnBlockUM.SubtitleUM.Style.textStyle: TextStyle @Composable @ReadOnlyComposable get() = when (this) { - EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodyMedium16 EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 } // endregion @@ -410,12 +396,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.common_stake), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Disabled, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = resourceReference(CoreResR.string.staking_notification_network_error_text), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Disabled, ), trailingUM = null, @@ -426,12 +412,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.common_staking), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = stringReference("Average APR 5.24%"), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Disabled, ), trailingUM = EarnBlockUM.TrailingUM.Button( @@ -445,12 +431,12 @@ private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvid iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.staking_enabled), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = stringReference("$ 12.34 rewards"), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Accent, ), trailingUM = EarnBlockUM.TrailingUM.Balance( @@ -475,14 +461,14 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr id = CoreResR.string.yield_module_token_details_earn_notification_subtitle, formatArgs = wrappedList("5.24"), ), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = resourceReference( CoreResR.string.yield_module_token_details_earn_notification_description, ), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Accent, ), trailingUM = EarnBlockUM.TrailingUM.Button( @@ -497,7 +483,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.yield_module_transaction_enter), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( @@ -505,7 +491,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr id = CoreResR.string.yield_module_average_apy, formatArgs = wrappedList("5.24"), ), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Accent, ), trailingUM = EarnBlockUM.TrailingUM.Button( @@ -521,7 +507,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.common_yield_mode), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Warning), ), @@ -530,7 +516,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr id = CoreResR.string.yield_module_average_apy, formatArgs = wrappedList("5.24"), ), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Accent, ), trailingUM = EarnBlockUM.TrailingUM.Button( @@ -546,7 +532,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.common_yield_mode), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Info), ), @@ -555,7 +541,7 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr id = CoreResR.string.yield_module_average_apy, formatArgs = wrappedList("5.24"), ), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Accent, ), trailingUM = EarnBlockUM.TrailingUM.Button( @@ -571,12 +557,12 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.common_yield_mode), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = resourceReference(CoreResR.string.common_enabling), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Accent, loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive), ), @@ -589,12 +575,12 @@ private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterPr iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_yield_disabling_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.common_yield_mode), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = resourceReference(CoreResR.string.common_disabling), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Disabled, loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted), ), diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokenaction/TokenActionRow.kt b/common/ui/src/main/java/com/tangem/common/ui/tokenaction/TokenActionRow.kt index 7d8e87530d..ebfad19e9c 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokenaction/TokenActionRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokenaction/TokenActionRow.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.layoutId @@ -61,10 +62,8 @@ fun TokenActionRow( val accentColor = accentColor(isEnabled) TangemRowContainer( modifier = modifier - .background( - color = TangemTheme.colors2.surface.level3, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), - ) + .clip(RoundedCornerShape(TangemTheme.dimens2.x5)) + .background(color = TangemTheme.colors2.surface.level3) .clickableWithHaptic( onClick = onClick, onLongClick = onLongClick, diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 45a73be769..597f1553c9 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -17,7 +17,7 @@ }, { "name": "APP_REDESIGN_ENABLED", - "version": "undefined" + "version": "5.40" }, { "name": "GASLESS_APPROVAL_ENABLED", @@ -106,5 +106,9 @@ { "name": "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED", "version": "5.39" + }, + { + "name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED", + "version": "undefined" } ] diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/FadeModifier.kt b/core/ui/src/main/java/com/tangem/core/ui/components/FadeModifier.kt index 5b934bcc3c..0a8c22119e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/FadeModifier.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/FadeModifier.kt @@ -93,6 +93,39 @@ fun Modifier.topFade( solidStop = solidStop, ) +/** + * Draws a vertical gradient fade over the top edge with custom [colorStops]. + * + * Stops are defined relative to [height] (0f = top, 1f = bottom of the fade region). + * + * Note: the gradient is drawn over the entire content area, so the last color stop's color + * extends below the fade region down to the bottom. Pass [Color.Transparent] as the last stop + * (or a color matching the underlying content) to avoid covering the area outside the fade. + */ +@Composable +fun Modifier.topFade(height: Dp, vararg colorStops: Pair): Modifier = composed { + drawWithContent { + drawContent() + + if (this.size.height <= 0f) return@drawWithContent + val fraction = (height.toPx() / this.size.height).coerceIn(0f, 1f) + if (fraction <= 0f) return@drawWithContent + + val (start, end) = this.size.getFadeOffsets(FadePosition.TOP) + + drawRect( + brush = Brush.linearGradient( + colorStops = colorStops + .map { (stop, color) -> stop.coerceIn(0f, 1f) * fraction to color } + .toTypedArray(), + start = start, + end = end, + ), + size = this.size, + ) + } +} + enum class FadePosition { TOP, BOTTOM, LEFT, RIGHT } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt index 0d2a37f3fd..35901d7a10 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -28,9 +29,10 @@ import androidx.compose.ui.unit.sp import com.tangem.core.ui.R import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign enum class AccountIconSize { - Default, Large, Medium, Small, ExtraSmall, RedesignedDefault + Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall } /** @@ -129,6 +131,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M AccountIconSize.Small -> TangemTheme.typography.subtitle2 AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28 + AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11 } val textSize by animateFloatAsState( @@ -148,6 +151,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M text = char.uppercase(), style = textStyle.copy(fontSize = textSize.sp), color = TangemTheme.colors.text.constantWhite, + textAlign = TextAlign.Center, ) } } @@ -161,6 +165,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) { AccountIconSize.Small -> 12.dp AccountIconSize.ExtraSmall -> 8.dp AccountIconSize.RedesignedDefault -> 20.dp + AccountIconSize.RedesignExtraSmall -> 8.dp } fun AccountIconSize.toBoxSize(): Dp = when (this) { @@ -170,6 +175,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) { AccountIconSize.Small -> 20.dp AccountIconSize.ExtraSmall -> 14.dp AccountIconSize.RedesignedDefault -> 40.dp + AccountIconSize.RedesignExtraSmall -> 16.dp } private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { @@ -179,6 +185,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { AccountIconSize.Small -> 6.dp AccountIconSize.ExtraSmall -> 4.dp AccountIconSize.RedesignedDefault -> 12.dp + AccountIconSize.RedesignExtraSmall -> 6.dp } @Preview(showBackground = true) @@ -194,6 +201,19 @@ private fun Preview() { } } +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewRedesigned() { + TangemThemePreviewRedesign { + Row( + modifier = Modifier.background(TangemTheme.colors.background.primary), + ) { + Sample() + } + } +} + @Composable private fun Sample() { var sizeState by remember { mutableStateOf(AccountIconSize.ExtraSmall) } @@ -206,8 +226,9 @@ private fun Sample() { AccountIconSize.Large -> AccountIconSize.Medium AccountIconSize.Medium -> AccountIconSize.Small AccountIconSize.Small -> AccountIconSize.ExtraSmall - AccountIconSize.ExtraSmall -> AccountIconSize.Default - AccountIconSize.RedesignedDefault -> AccountIconSize.Large + AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault + AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall + AccountIconSize.RedesignExtraSmall -> AccountIconSize.Default } }) { Text("Change") } @@ -225,5 +246,6 @@ private fun Sample() { AccountCharIcon(char = 'M', color = Color.Magenta, size = AccountIconSize.Medium) AccountCharIcon(char = 'S', color = Color.DarkGray, size = AccountIconSize.Small) AccountCharIcon(char = 'E', color = Color.Green, size = AccountIconSize.ExtraSmall) + AccountCharIcon(char = 'D', color = Color.LightGray, size = AccountIconSize.RedesignExtraSmall) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/fade/TangemFade.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/fade/TangemFade.kt new file mode 100644 index 0000000000..dba8e035dd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/fade/TangemFade.kt @@ -0,0 +1,166 @@ +@file:Suppress("MagicNumber") + +package com.tangem.core.ui.ds2.fade + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeTint + +private const val SOLID_RATIO = 40f / 96f +private const val HARD_ALPHA = 0.95f +private const val SOFT_ALPHA = 0.6f +private val FADE_HEIGHT = 96.dp +private val BLUR_RADIUS = 20.dp + +/** + * Design-system fade overlay used at the top or bottom edge of scrollable content. + * + * Renders a 96dp-tall gradient (and optional solid block in the [TangemFade.Variant.Hard] + * variant) tinted with `colors3.bg.primary`, so it blends with the page background and masks + * content as it scrolls under the edge. Place it inside a [Box] and align with + * [androidx.compose.ui.Alignment.TopCenter] / [androidx.compose.ui.Alignment.BottomCenter] + * depending on [position]. + * + * @param position which edge the fade is anchored to — controls the gradient direction. + * @param variant [TangemFade.Variant.Hard] adds an opaque solid block next to the edge for a + * harder cut-off, [TangemFade.Variant.Soft] is gradient-only and gentler. + * @param blur when `true`, the content under the fade is blurred via Haze (radius 20dp, + * progressive intensity matching [position]). + * @param backgroundColor base color of the fade gradient — defaults to `colors3.bg.primary` so + * the fade blends with the standard page background. + * @param modifier modifier applied to the fade's root. + */ +@Composable +fun TangemFade( + position: TangemFade.Position, + modifier: Modifier = Modifier, + variant: TangemFade.Variant = TangemFade.Variant.Soft, + blur: Boolean = false, + backgroundColor: Color = TangemTheme.colors3.bg.primary, +) { + val hazeState = LocalHazeState.current + val brush = buildBrush(color = backgroundColor, position = position, variant = variant) + + val blurModifier = if (blur) { + Modifier.hazeEffectTangem(state = hazeState) { + blurRadius = BLUR_RADIUS + fallbackTint = HazeTint(Color.Transparent) + progressive = HazeProgressive.verticalGradient( + startIntensity = if (position == TangemFade.Position.Top) 1f else 0f, + endIntensity = if (position == TangemFade.Position.Top) 0f else 1f, + preferPerformance = true, + ) + } + } else { + Modifier + } + + Box( + modifier = modifier + .fillMaxWidth() + .height(FADE_HEIGHT) + .then(blurModifier) + .background(brush), + ) +} + +private fun buildBrush(color: Color, position: TangemFade.Position, variant: TangemFade.Variant): Brush { + val opaque = color.copy(alpha = HARD_ALPHA) + val soft = color.copy(alpha = SOFT_ALPHA) + val transparent = Color.Transparent + + return when (variant) { + TangemFade.Variant.Hard -> when (position) { + TangemFade.Position.Top -> Brush.verticalGradient( + colorStops = arrayOf( + 0f to opaque, + SOLID_RATIO to opaque, + 1f to transparent, + ), + ) + TangemFade.Position.Bottom -> Brush.verticalGradient( + colorStops = arrayOf( + 0f to transparent, + 1f - SOLID_RATIO to opaque, + 1f to opaque, + ), + ) + } + TangemFade.Variant.Soft -> when (position) { + TangemFade.Position.Top -> Brush.verticalGradient(colors = listOf(soft, transparent)) + TangemFade.Position.Bottom -> Brush.verticalGradient(colors = listOf(transparent, soft)) + } + } +} + +object TangemFade { + enum class Position { Top, Bottom } + enum class Variant { Hard, Soft } +} + +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TangemFade_Preview() { + TangemThemePreviewRedesign { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .background(TangemTheme.colors3.bg.secondary) + .padding(12.dp), + ) { + TangemFade.Variant.entries.forEach { variant -> + TangemFade.Position.entries.forEach { position -> + FadePreviewRow(variant = variant, position = position) + } + } + } + } +} + +@Composable +private fun FadePreviewRow(variant: TangemFade.Variant, position: TangemFade.Position) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "${variant.name} / ${position.name}", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors3.text.primary, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background(TangemTheme.colors3.bg.accent.blue), + ) { + TangemFade( + position = position, + variant = variant, + modifier = Modifier.align( + when (position) { + TangemFade.Position.Top -> Alignment.TopCenter + TangemFade.Position.Bottom -> Alignment.BottomCenter + }, + ), + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt new file mode 100644 index 0000000000..e5c6f71471 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt @@ -0,0 +1,468 @@ +package com.tangem.core.ui.ds2.row + +import android.content.res.Configuration +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.RippleConfiguration +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.extensions.conditionalCompose +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +/** Controls how the title and value sides share horizontal space in a [TangemRow]. */ +@Immutable +enum class TangemRowContentLead { Equal, Start, End } + +/** Vertical alignment between the start slot, content labels, and end slot in a [TangemRow]. */ +@Immutable +enum class TangemRowVerticalAlignment { Top, Center } + +/** + * Design-system v2 row: a list-item container with start / end slots, title+subtitle on the + * leading side, value+subvalue on the trailing side, and an optional slot below the row. + * + * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=2344-1406) + * + * Usage: + * ``` + * TangemRow( + * titleSlot = { TangemRowText("Network fee", TangemRowTextRole.Title) }, + * valueSlot = { TangemRowText("0.0001 ETH", TangemRowTextRole.Value) }, + * contentLead = TangemRowContentLead.Start, + * onClick = { /* ... */ }, + * ) + * ``` + * + * @param modifier Modifier applied to the row container. + * @param divider Draws an inset bottom divider. + * @param includeInnerPaddings Applies the default row padding. Disable when wrapped in a container + * that already handles padding. + * @param contentLead Strategy for sharing space between the title and value sides. + * See [TangemRowContentLead]. + * @param verticalAlignment Vertical alignment of the slots. See [TangemRowVerticalAlignment]. + * @param titleSlot Leading primary label. + * @param subtitleSlot Leading secondary label rendered under [titleSlot]. + * @param valueSlot Trailing primary label. + * @param subvalueSlot Trailing secondary label rendered under [valueSlot]. + * @param startSlot Leading icon / control slot. + * @param endSlot Trailing icon / control slot. + * @param extraBottomSlot Full-width content rendered below the row. + * @param interactionSource Interaction source used when [onClick] is non-null. + * @param onClick Click handler. `null` makes the row non-interactive. + */ +@Suppress("LongParameterList") +@Composable +fun TangemRow( + modifier: Modifier = Modifier, + divider: Boolean = false, + includeInnerPaddings: Boolean = true, + contentLead: TangemRowContentLead = TangemRowContentLead.Equal, + verticalAlignment: TangemRowVerticalAlignment = TangemRowVerticalAlignment.Top, + titleSlot: (@Composable RowScope.() -> Unit)? = null, + subtitleSlot: (@Composable RowScope.() -> Unit)? = null, + valueSlot: (@Composable RowScope.() -> Unit)? = null, + subvalueSlot: (@Composable RowScope.() -> Unit)? = null, + startSlot: (@Composable BoxScope.() -> Unit)? = null, + endSlot: (@Composable BoxScope.() -> Unit)? = null, + extraBottomSlot: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, + onClick: (() -> Unit)? = null, +) { + val resolvedInteractionSource: MutableInteractionSource? = if (onClick != null) { + interactionSource ?: remember { MutableInteractionSource() } + } else { + null + } + val isFocused = if (resolvedInteractionSource != null) { + val isFocusedByState by resolvedInteractionSource.collectIsFocusedAsState() + isFocusedByState + } else { + false + } + + WithRowRipple(enabled = onClick != null) { + Column( + modifier = modifier + .conditionalCompose(isFocused) { focusBorder() } + .then( + if (onClick != null) { + Modifier.clickable( + interactionSource = resolvedInteractionSource, + indication = LocalIndication.current, + onClick = onClick, + ) + } else { + Modifier + }, + ) + .conditionalCompose(divider) { + bottomDivider( + color = TangemTheme.colors3.border.secondary, + width = TangemTheme.dimens3.borderWidth.sm, + horizontalInset = TangemTheme.dimens3.spacing.s200, + ) + } + .conditionalCompose(includeInnerPaddings) { + padding(TangemTheme.dimens3.spacing.s200) + }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = verticalAlignment.toCompose(), + ) { + SideSlot(slot = startSlot, position = SideSlotPosition.Start) + ContentLabels( + modifier = Modifier.weight(1f), + contentLead = contentLead, + verticalAlignment = verticalAlignment, + titleSlot = titleSlot, + subtitleSlot = subtitleSlot, + valueSlot = valueSlot, + subvalueSlot = subvalueSlot, + ) + SideSlot(slot = endSlot, position = SideSlotPosition.End) + } + if (extraBottomSlot != null) { + SpacerH(TangemTheme.dimens3.spacing.s100) + extraBottomSlot() + } + } + } +} + +@Composable +private fun WithRowRipple(enabled: Boolean, content: @Composable () -> Unit) { + if (enabled) { + CompositionLocalProvider(LocalRippleConfiguration provides tangemRowRipple(), content = content) + } else { + content() + } +} + +@Composable +@ReadOnlyComposable +private fun tangemRowRipple(): RippleConfiguration = RippleConfiguration( + color = TangemTheme.colors3.interaction.press.default, + rippleAlpha = RippleAlpha( + draggedAlpha = 0f, + focusedAlpha = 0f, + hoveredAlpha = 0.05f, + pressedAlpha = 0.1f, + ), +) + +private fun TangemRowVerticalAlignment.toCompose(): Alignment.Vertical = when (this) { + TangemRowVerticalAlignment.Top -> Alignment.Top + TangemRowVerticalAlignment.Center -> Alignment.CenterVertically +} + +private enum class SideSlotPosition { Start, End } + +@Composable +private fun SideSlot(slot: (@Composable BoxScope.() -> Unit)?, position: SideSlotPosition) { + if (slot == null) return + val spacing = TangemTheme.dimens3.spacing.s150 + val padding = when (position) { + SideSlotPosition.Start -> Modifier.padding(end = spacing) + SideSlotPosition.End -> Modifier.padding(start = spacing) + } + Box(modifier = padding, content = slot) +} + +/** + * Lays out the title/value columns side-by-side with [contentLead]-aware width sharing. + */ +@Suppress("UnnecessaryParentheses", "LongParameterList") +@Composable +private fun ContentLabels( + contentLead: TangemRowContentLead, + verticalAlignment: TangemRowVerticalAlignment, + titleSlot: (@Composable RowScope.() -> Unit)?, + subtitleSlot: (@Composable RowScope.() -> Unit)?, + valueSlot: (@Composable RowScope.() -> Unit)?, + subvalueSlot: (@Composable RowScope.() -> Unit)?, + modifier: Modifier = Modifier, +) { + val hasLeft = titleSlot != null || subtitleSlot != null + val hasRight = valueSlot != null || subvalueSlot != null + if (!hasLeft && !hasRight) return + + Layout( + modifier = modifier, + content = { + // Always emit two roots so `measurables` indices are stable in the measure block. + LabelColumnContent(primary = titleSlot, secondary = subtitleSlot, alignment = Alignment.Start) + LabelColumnContent(primary = valueSlot, secondary = subvalueSlot, alignment = Alignment.End) + }, + ) { measurables, constraints -> + val titleMeasurable = measurables[0] + val valueMeasurable = measurables[1] + // Fall back to summed intrinsics when parent provides unbounded width — `layout()` cannot + // report an infinite size. + val rowWidth = if (constraints.hasBoundedWidth) { + constraints.maxWidth + } else { + titleMeasurable.maxIntrinsicWidth(Int.MAX_VALUE) + valueMeasurable.maxIntrinsicWidth(Int.MAX_VALUE) + } + + val (titleSlotWidth, valueSlotWidth) = when { + !hasLeft -> 0 to rowWidth + !hasRight -> rowWidth to 0 + else -> when (contentLead) { + TangemRowContentLead.Equal -> (rowWidth / 2) to (rowWidth - rowWidth / 2) + TangemRowContentLead.Start -> { + // Value hugs (intrinsic), title fills the rest. Cap the hugging side at half + // the row so the filling label never collapses to zero on overflow — degrades + // to an equal split when the hugging side wants more than half. + val v = valueMeasurable.maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(rowWidth / 2) + (rowWidth - v) to v + } + TangemRowContentLead.End -> { + // Title hugs (intrinsic), value fills the rest. Same half-row cap as above. + val t = titleMeasurable.maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(rowWidth / 2) + t to (rowWidth - t) + } + } + } + + val titlePlaceable = titleMeasurable.measure( + constraints.copy(minWidth = 0, maxWidth = titleSlotWidth), + ) + val valuePlaceable = valueMeasurable.measure( + constraints.copy(minWidth = 0, maxWidth = valueSlotWidth), + ) + + val rowHeight = maxOf(titlePlaceable.height, valuePlaceable.height) + val yFor: (Int) -> Int = when (verticalAlignment) { + TangemRowVerticalAlignment.Top -> { _ -> 0 } + TangemRowVerticalAlignment.Center -> { h -> (rowHeight - h) / 2 } + } + + layout(rowWidth, rowHeight) { + titlePlaceable.placeRelative(x = 0, y = yFor(titlePlaceable.height)) + // Filling side is pinned to the row's trailing edge. + valuePlaceable.placeRelative( + x = rowWidth - valuePlaceable.width, + y = yFor(valuePlaceable.height), + ) + } + } +} + +@Composable +private fun LabelColumnContent( + primary: (@Composable RowScope.() -> Unit)?, + secondary: (@Composable RowScope.() -> Unit)?, + alignment: Alignment.Horizontal, +) { + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens3.spacing.s025), + horizontalAlignment = alignment, + ) { + if (primary != null) LabelRow(alignment = alignment, content = primary) + if (secondary != null) LabelRow(alignment = alignment, content = secondary) + } +} + +@Composable +private fun LabelRow(alignment: Alignment.Horizontal, content: @Composable RowScope.() -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy( + space = TangemTheme.dimens3.spacing.s050, + alignment = alignment, + ), + content = content, + ) +} + +/** Semantic role of a label inside a [TangemRow], driving its typography, color and alignment. */ +@Immutable +enum class TangemRowTextRole { Title, Subtitle, Value, Subvalue } + +/** + * Default text styling for [TangemRow] label slots. + * + * Usage: + * ``` + * TangemRowText(text = "Network fee", role = TangemRowTextRole.Title) + * ``` + * + * @param text Label text. + * @param role Semantic role. See [TangemRowTextRole]. + * @param modifier Modifier applied to the underlying [Text]. + * @param maxLines Maximum number of visible lines before truncation. + * @param overflow Overflow behavior. Defaults to ellipsis. + */ +@Composable +fun TangemRowText( + text: String, + role: TangemRowTextRole, + modifier: Modifier = Modifier, + maxLines: Int = 1, + overflow: TextOverflow = TextOverflow.Ellipsis, +) { + Text( + text = text, + modifier = modifier, + color = rowTextColor(role), + style = rowTextStyle(role), + textAlign = rowTextAlign(role), + maxLines = maxLines, + overflow = overflow, + ) +} + +@Composable +private fun rowTextStyle(role: TangemRowTextRole): TextStyle = when (role) { + TangemRowTextRole.Title, TangemRowTextRole.Value -> TangemTheme.typography3.body.medium + TangemRowTextRole.Subtitle, TangemRowTextRole.Subvalue -> TangemTheme.typography3.caption.medium +} + +@Composable +private fun rowTextColor(role: TangemRowTextRole): Color = when (role) { + TangemRowTextRole.Title, TangemRowTextRole.Value -> TangemTheme.colors3.text.primary + TangemRowTextRole.Subtitle, TangemRowTextRole.Subvalue -> TangemTheme.colors3.text.secondary +} + +private fun rowTextAlign(role: TangemRowTextRole): TextAlign = when (role) { + TangemRowTextRole.Title, TangemRowTextRole.Subtitle -> TextAlign.Start + TangemRowTextRole.Value, TangemRowTextRole.Subvalue -> TextAlign.End +} + +@Composable +private fun Modifier.focusBorder(): Modifier { + val radius = TangemTheme.dimens3.borderRadius.b200 + val shape = remember(radius) { RoundedCornerShape(radius) } + return border( + width = TangemTheme.dimens3.borderWidth.md, + color = TangemTheme.colors3.interaction.focusRing.default, + shape = shape, + ) +} + +private fun Modifier.bottomDivider(color: Color, width: Dp, horizontalInset: Dp): Modifier = drawWithContent { + drawContent() + val stroke = width.toPx() + val inset = horizontalInset.toPx() + val y = size.height - stroke / 2f + drawLine( + color = color, + start = Offset(x = inset, y = y), + end = Offset(x = size.width - inset, y = y), + strokeWidth = stroke, + ) +} + +// region Previews + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TangemRowPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(vertical = 16.dp), + ) { + // Equal lead, text only, with divider + TangemRow( + divider = true, + contentLead = TangemRowContentLead.Equal, + titleSlot = { TangemRowText(text = "Title", role = TangemRowTextRole.Title) }, + subtitleSlot = { TangemRowText(text = "Subtitle", role = TangemRowTextRole.Subtitle) }, + valueSlot = { TangemRowText(text = "Value", role = TangemRowTextRole.Value) }, + subvalueSlot = { TangemRowText(text = "Subvalue", role = TangemRowTextRole.Subvalue) }, + ) + // Start lead — title hugs, value side fills + TangemRow( + divider = true, + contentLead = TangemRowContentLead.Start, + titleSlot = { TangemRowText(text = "Network fee", role = TangemRowTextRole.Title) }, + valueSlot = { TangemRowText(text = "0.0001 ETH", role = TangemRowTextRole.Value) }, + subvalueSlot = { TangemRowText(text = "≈ $0.32", role = TangemRowTextRole.Subvalue) }, + ) + // End lead — value hugs, title side fills + TangemRow( + divider = true, + contentLead = TangemRowContentLead.End, + titleSlot = { + TangemRowText( + text = "Recipient with a very long address that should ellipsize", + role = TangemRowTextRole.Title, + ) + }, + valueSlot = { TangemRowText(text = "0x1234…abcd", role = TangemRowTextRole.Value) }, + ) + // Side slots + clickable + centered alignment + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + startSlot = { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_chevron_24), + contentDescription = null, + tint = TangemTheme.colors3.icon.primary, + ) + }, + titleSlot = { TangemRowText(text = "Settings", role = TangemRowTextRole.Title) }, + subtitleSlot = { + TangemRowText(text = "Tap to configure", role = TangemRowTextRole.Subtitle) + }, + endSlot = { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_chevron_24), + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + ) + }, + onClick = {}, + ) + // Title only — exercises nullable slots / single LabelRow path + TangemRow( + titleSlot = { TangemRowText(text = "Single-line row", role = TangemRowTextRole.Title) }, + ) + } + } +} + +// endregion \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt index 9d3c18477f..33fe2fd816 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt @@ -17,8 +17,6 @@ import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.ds.opportunities.OpportunitiesBG import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.* @@ -44,53 +42,52 @@ internal fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier: @Composable private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) { - OpportunitiesBG( + Column( modifier = modifier .width(178.dp) .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) - .clickable(onClick = onClick), - icon = TangemIconUM.Currency(item.currencyIconState), + .background(TangemTheme.colors2.surface.level3) + .clickable(onClick = onClick) + .padding(12.dp), ) { - Column(modifier = Modifier.padding(12.dp)) { - CurrencyIcon( - state = item.currencyIconState, - shouldDisplayNetwork = true, - networkBadgeSize = TangemTheme.dimens2.x4, - iconSize = TangemTheme.dimens2.x10, - networkBadgeBackground = TangemTheme.colors.background.action, - ) + CurrencyIcon( + state = item.currencyIconState, + shouldDisplayNetwork = true, + networkBadgeSize = TangemTheme.dimens2.x4, + iconSize = TangemTheme.dimens2.x10, + networkBadgeBackground = TangemTheme.colors.background.action, + ) - SpacerH(22.dp) - - Row( - verticalAlignment = Alignment.Bottom, - ) { - Text( - modifier = Modifier.weight(weight = 1f, fill = false), - text = item.tokenName.resolveReference(), - color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography2.bodySemibold16, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - SpacerW(4.dp) - Text( - text = item.symbol.resolveReference(), - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography2.captionMedium12, - maxLines = 1, - ) - } - - SpacerH(2.dp) + SpacerH(22.dp) + Row( + verticalAlignment = Alignment.Bottom, + ) { Text( - text = item.earnValue.resolveReference(), - color = TangemTheme.colors2.text.status.positive, + modifier = Modifier.weight(weight = 1f, fill = false), + text = item.tokenName.resolveReference(), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography2.bodySemibold16, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + SpacerW(4.dp) + Text( + text = item.symbol.resolveReference(), + color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography2.captionMedium12, maxLines = 1, ) } + + SpacerH(2.dp) + + Text( + text = item.earnValue.resolveReference(), + color = TangemTheme.colors2.text.status.positive, + style = TangemTheme.typography2.captionMedium12, + maxLines = 1, + ) } } 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 637a15eedf..51e22e3f0a 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 @@ -7,7 +7,10 @@ import com.tangem.core.ui.ds.message.TangemMessageEffect import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.ds2.badge.TangemBadge import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.fade.TangemFade import com.tangem.core.ui.ds2.loader.TangemLoaderSize +import com.tangem.core.ui.ds2.row.TangemRowContentLead +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle internal sealed interface StoryBookPage @@ -176,6 +179,55 @@ internal data class TangemButtonStory( } } +@Suppress("BooleanPropertyNaming") +internal data class TangemRowStory( + val contentLead: TangemRowContentLead, + val verticalAlignment: TangemRowVerticalAlignment, + val background: Background, + val divider: Boolean, + val includeInnerPaddings: Boolean, + val isClickable: Boolean, + val hasStartSlot: Boolean, + val hasEndSlot: Boolean, + val hasSubtitle: Boolean, + val hasValue: Boolean, + val hasSubvalue: Boolean, + val hasExtraBottom: Boolean, + val longTitle: Boolean, + val textScale: Float, + val onContentLeadChange: (TangemRowContentLead) -> Unit, + val onVerticalAlignmentChange: (TangemRowVerticalAlignment) -> Unit, + val onBackgroundChange: (Background) -> Unit, + val onDividerToggle: () -> Unit, + val onInnerPaddingsToggle: () -> Unit, + val onClickableToggle: () -> Unit, + val onStartSlotToggle: () -> Unit, + val onEndSlotToggle: () -> Unit, + val onSubtitleToggle: () -> Unit, + val onValueToggle: () -> Unit, + val onSubvalueToggle: () -> Unit, + val onExtraBottomToggle: () -> Unit, + val onLongTitleToggle: () -> Unit, + val onTextScaleChange: (Float) -> Unit, +) : DsStoryBookPage { + + /** Backdrop the row preview is rendered on top of. */ + enum class Background(val label: String) { + BgPrimary("bg.primary"), + BgSecondary("bg.secondary"), + BgBrand("bg.brand"), + BgInverse("bg.inverse"), + } +} +internal data class TangemFadeStory( + val variant: TangemFade.Variant, + val position: TangemFade.Position, + val isBlur: Boolean, + val onVariantChange: (TangemFade.Variant) -> Unit, + val onPositionChange: (TangemFade.Position) -> Unit, + val onBlurToggle: () -> Unit, +) : DsStoryBookPage + internal data class TangemBadgeV2Story( val variant: TangemBadge.Variant, val status: TangemBadge.Status, 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 1bc217e693..ba469619c7 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 @@ -17,7 +17,9 @@ import com.tangem.feature.tester.presentation.storybook.entity.DsComponentsListS import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory import com.tangem.feature.tester.presentation.storybook.page.ds.badge.tangemBadgeV2StoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemButtonStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory private data class DsStoryItem(val title: String, val factory: StoryPageFactory) @@ -26,7 +28,9 @@ private fun buildDsStories() = listOf( DsStoryItem(title = "⏳ TangemLoader", factory = tangemLoaderStoryFactory), DsStoryItem(title = "🔘 TangemButton", factory = tangemButtonStoryFactory), DsStoryItem(title = "🏷️ TangemBadge", factory = tangemBadgeV2StoryFactory), + DsStoryItem(title = "📋 TangemRow", factory = tangemRowStoryFactory), DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory), + DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt index 78539107a0..6d0d5c0f01 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/badge/TangemBadgeV2Story.kt @@ -18,7 +18,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text @@ -54,13 +56,20 @@ internal fun TangemBadgeV2Story(state: TangemBadgeV2Story, modifier: Modifier = .padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + // Preview stays pinned at the top. ComponentPreview(state = state) - VariantSelector(selected = state.variant, onSelect = state.onVariantChange) - StatusSelector(selected = state.status, onSelect = state.onStatusChange) - SizeSelector(selected = state.size, onSelect = state.onSizeChange) - BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) - TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange) - Toggles(state = state) + // Only the controls scroll. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VariantSelector(selected = state.variant, onSelect = state.onVariantChange) + StatusSelector(selected = state.status, onSelect = state.onStatusChange) + SizeSelector(selected = state.size, onSelect = state.onSizeChange) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) + TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange) + Toggles(state = state) + } } } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt index a465ac1166..7eccf7507b 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/button/TangemButtonStory.kt @@ -50,12 +50,19 @@ internal fun TangemButtonStory(state: TangemButtonStory, modifier: Modifier = Mo .padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + // Preview stays pinned at the top. ComponentPreview(state = state) - VariantSelector(selected = state.variant, onSelect = state.onVariantChange) - SizeSelector(selected = state.size, onSelect = state.onSizeChange) - BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) - TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange) - Toggles(state = state) + // Only the controls scroll. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VariantSelector(selected = state.variant, onSelect = state.onVariantChange) + SizeSelector(selected = state.size, onSelect = state.onSizeChange) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) + TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange) + Toggles(state = state) + } } } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/fade/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/fade/Build.kt new file mode 100644 index 0000000000..9fa47f0e0c --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/fade/Build.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.tester.presentation.storybook.page.ds.fade + +import com.tangem.core.ui.ds2.fade.TangemFade +import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemFadeStory { + return TangemFadeStory( + variant = TangemFade.Variant.Hard, + position = TangemFade.Position.Top, + isBlur = false, + onVariantChange = { variant -> + updateStory { it.copy(variant = variant) } + }, + onPositionChange = { position -> + updateStory { it.copy(position = position) } + }, + onBlurToggle = { + updateStory { it.copy(isBlur = !it.isBlur) } + }, + ) +} + +internal val tangemFadeStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/fade/TangemFadeStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/fade/TangemFadeStory.kt new file mode 100644 index 0000000000..48cb741a28 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/fade/TangemFadeStory.kt @@ -0,0 +1,245 @@ +@file:Suppress("MagicNumber") + +package com.tangem.feature.tester.presentation.storybook.page.ds.fade + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.ds2.fade.TangemFade +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory + +@Composable +internal fun TangemFadeStory(state: TangemFadeStory, modifier: Modifier = Modifier) { + val hazeState = LocalHazeState.current + DisposableEffect(state.isBlur) { + val wasBlurEnabled = hazeState.blurEnabled + hazeState.blurEnabled = state.isBlur + onDispose { hazeState.blurEnabled = wasBlurEnabled } + } + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ComponentPreview(state = state) + VariantSelector(selected = state.variant, onSelect = state.onVariantChange) + PositionSelector(selected = state.position, onSelect = state.onPositionChange) + Toggles(state = state) + } +} + +@Composable +private fun ComponentPreview(state: TangemFadeStory) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(240.dp) + .clip(RoundedCornerShape(16.dp)), + ) { + Backdrop( + modifier = Modifier + .fillMaxSize() + .hazeSourceTangem(zIndex = 0f), + ) + TangemFade( + position = state.position, + variant = state.variant, + blur = state.isBlur, + modifier = Modifier.align( + when (state.position) { + TangemFade.Position.Top -> Alignment.TopCenter + TangemFade.Position.Bottom -> Alignment.BottomCenter + }, + ), + ) + } +} + +@Composable +private fun Backdrop(modifier: Modifier = Modifier) { + val bands = remember { + listOf( + Color(0xFFFF1744), + Color(0xFFFF9100), + Color(0xFFFFEA00), + Color(0xFF00E676), + Color(0xFF00B8D4), + Color(0xFF2962FF), + Color(0xFFD500F9), + ) + } + val stops = remember(bands) { + buildList { + bands.forEachIndexed { index, color -> + val start = index.toFloat() / bands.size + val end = (index + 1).toFloat() / bands.size + add(start to color) + add(end to color) + } + }.toTypedArray() + } + val tilePx = with(LocalDensity.current) { 160.dp.toPx() } + Box( + modifier = modifier.background( + brush = Brush.linearGradient( + colorStops = stops, + start = Offset(0f, 0f), + end = Offset(tilePx, tilePx), + tileMode = TileMode.Repeated, + ), + ), + ) +} + +@Composable +private fun VariantSelector(selected: TangemFade.Variant, onSelect: (TangemFade.Variant) -> Unit) { + Section(label = "Variant") { + ChipGrid( + items = TangemFade.Variant.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun PositionSelector(selected: TangemFade.Position, onSelect: (TangemFade.Position) -> Unit) { + Section(label = "Position") { + ChipGrid( + items = TangemFade.Position.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun Toggles(state: TangemFadeStory) { + Section(label = "Flags") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToggleRow(label = "blur", checked = state.isBlur, onToggle = state.onBlurToggle) + } + } +} + +@Composable +private fun Section(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = label, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } +} + +@Composable +private fun ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TangemTheme.colors2.surface.level2) + .clickable(onClick = onToggle) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = if (checked) "ON" else "OFF", + style = TangemTheme.typography.caption2, + color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/Build.kt new file mode 100644 index 0000000000..c04c585037 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/Build.kt @@ -0,0 +1,71 @@ +package com.tangem.feature.tester.presentation.storybook.page.ds.row + +import com.tangem.core.ui.ds2.row.TangemRowContentLead +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemRowStory { + return TangemRowStory( + contentLead = TangemRowContentLead.Equal, + verticalAlignment = TangemRowVerticalAlignment.Top, + background = TangemRowStory.Background.BgPrimary, + divider = false, + includeInnerPaddings = true, + isClickable = false, + hasStartSlot = false, + hasEndSlot = false, + hasSubtitle = true, + hasValue = true, + hasSubvalue = true, + hasExtraBottom = false, + longTitle = false, + textScale = 1f, + onContentLeadChange = { contentLead -> + updateStory { it.copy(contentLead = contentLead) } + }, + onVerticalAlignmentChange = { alignment -> + updateStory { it.copy(verticalAlignment = alignment) } + }, + onBackgroundChange = { background -> + updateStory { it.copy(background = background) } + }, + onDividerToggle = { + updateStory { it.copy(divider = !it.divider) } + }, + onInnerPaddingsToggle = { + updateStory { it.copy(includeInnerPaddings = !it.includeInnerPaddings) } + }, + onClickableToggle = { + updateStory { it.copy(isClickable = !it.isClickable) } + }, + onStartSlotToggle = { + updateStory { it.copy(hasStartSlot = !it.hasStartSlot) } + }, + onEndSlotToggle = { + updateStory { it.copy(hasEndSlot = !it.hasEndSlot) } + }, + onSubtitleToggle = { + updateStory { it.copy(hasSubtitle = !it.hasSubtitle) } + }, + onValueToggle = { + updateStory { it.copy(hasValue = !it.hasValue) } + }, + onSubvalueToggle = { + updateStory { it.copy(hasSubvalue = !it.hasSubvalue) } + }, + onExtraBottomToggle = { + updateStory { it.copy(hasExtraBottom = !it.hasExtraBottom) } + }, + onLongTitleToggle = { + updateStory { it.copy(longTitle = !it.longTitle) } + }, + onTextScaleChange = { scale -> + updateStory { it.copy(textScale = scale) } + }, + ) +} + +internal val tangemRowStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/TangemRowStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/TangemRowStory.kt new file mode 100644 index 0000000000..998cf58cf5 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/row/TangemRowStory.kt @@ -0,0 +1,369 @@ +@file:Suppress("MagicNumber") + +package com.tangem.feature.tester.presentation.storybook.page.ds.row + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowContentLead +import com.tangem.core.ui.ds2.row.TangemRowText +import com.tangem.core.ui.ds2.row.TangemRowTextRole +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory.Background + +private const val SHORT_TITLE = "Title" +private const val LONG_TITLE = "Recipient address that should ellipsize when constrained by width" +private const val SUBTITLE = "Subtitle" +private const val VALUE = "0.0421 ETH" +private const val SUBVALUE = "≈ $124.80" + +@Composable +internal fun TangemRowStory(state: TangemRowStory, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Preview stays pinned at the top. + ComponentPreview(state = state) + // Only the controls scroll. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ContentLeadSelector(selected = state.contentLead, onSelect = state.onContentLeadChange) + VerticalAlignmentSelector( + selected = state.verticalAlignment, + onSelect = state.onVerticalAlignmentChange, + ) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) + TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange) + Toggles(state = state) + } + } +} + +@Composable +private fun ComponentPreview(state: TangemRowStory) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)), + ) { + PreviewBackground(background = state.background, modifier = Modifier.matchParentSize()) + val baseDensity = LocalDensity.current + val scaledDensity = remember(baseDensity, state.textScale) { + Density(density = baseDensity.density, fontScale = state.textScale) + } + CompositionLocalProvider(LocalDensity provides scaledDensity) { + Box(modifier = Modifier.padding(vertical = 24.dp)) { + StorybookRow(state = state) + } + } + } +} + +@Composable +private fun StorybookRow(state: TangemRowStory) { + val titleText = if (state.longTitle) LONG_TITLE else SHORT_TITLE + TangemRow( + contentLead = state.contentLead, + verticalAlignment = state.verticalAlignment, + divider = state.divider, + includeInnerPaddings = state.includeInnerPaddings, + titleSlot = { TangemRowText(text = titleText, role = TangemRowTextRole.Title) }, + subtitleSlot = if (state.hasSubtitle) { + { TangemRowText(text = SUBTITLE, role = TangemRowTextRole.Subtitle) } + } else { + null + }, + valueSlot = if (state.hasValue) { + { TangemRowText(text = VALUE, role = TangemRowTextRole.Value) } + } else { + null + }, + subvalueSlot = if (state.hasSubvalue) { + { TangemRowText(text = SUBVALUE, role = TangemRowTextRole.Subvalue) } + } else { + null + }, + startSlot = if (state.hasStartSlot) { + { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_information_24), + contentDescription = null, + tint = TangemTheme.colors3.icon.primary, + ) + } + } else { + null + }, + endSlot = if (state.hasEndSlot) { + { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_chevron_24), + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + ) + } + } else { + null + }, + extraBottomSlot = if (state.hasExtraBottom) { + { + Text( + text = "Extra bottom slot — multi-line description content.", + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, + ) + } + } else { + null + }, + onClick = if (state.isClickable) { + { /* no-op — ripple + focus demo */ } + } else { + null + }, + ) +} + +@Composable +private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) { + when (background) { + Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary)) + Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary)) + Background.BgBrand -> Box(modifier.background(TangemTheme.colors3.bg.brand)) + Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse)) + } +} + +@Composable +private fun ContentLeadSelector(selected: TangemRowContentLead, onSelect: (TangemRowContentLead) -> Unit) { + Section(label = "Content lead") { + ChipGrid( + items = TangemRowContentLead.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun VerticalAlignmentSelector( + selected: TangemRowVerticalAlignment, + onSelect: (TangemRowVerticalAlignment) -> Unit, +) { + Section(label = "Vertical alignment") { + ChipGrid( + items = TangemRowVerticalAlignment.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) { + Section(label = "Background") { + ChipGrid( + items = Background.entries, + label = { it.label }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun TextScaleSlider(value: Float, onChange: (Float) -> Unit) { + Section(label = "Text scale: ${"%.2f".format(value)}x") { + Slider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = value, + onValueChange = onChange, + valueRange = 0.5f..2f, + steps = 14, + colors = SliderDefaults.colors( + thumbColor = TangemTheme.colors.text.accent, + activeTrackColor = TangemTheme.colors.text.accent, + activeTickColor = TangemTheme.colors2.surface.level3, + inactiveTrackColor = TangemTheme.colors2.surface.level3, + inactiveTickColor = TangemTheme.colors.text.accent, + ), + ) + } +} + +@Composable +private fun Toggles(state: TangemRowStory) { + Section(label = "Flags") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToggleRow(label = "divider", checked = state.divider, onToggle = state.onDividerToggle) + ToggleRow( + label = "includeInnerPaddings", + checked = state.includeInnerPaddings, + onToggle = state.onInnerPaddingsToggle, + ) + ToggleRow( + label = "clickable (ripple + focus)", + checked = state.isClickable, + onToggle = state.onClickableToggle, + ) + ToggleRow( + label = "startSlot (leading icon)", + checked = state.hasStartSlot, + onToggle = state.onStartSlotToggle, + ) + ToggleRow( + label = "endSlot (trailing icon)", + checked = state.hasEndSlot, + onToggle = state.onEndSlotToggle, + ) + ToggleRow(label = "subtitle", checked = state.hasSubtitle, onToggle = state.onSubtitleToggle) + ToggleRow(label = "value", checked = state.hasValue, onToggle = state.onValueToggle) + ToggleRow(label = "subvalue", checked = state.hasSubvalue, onToggle = state.onSubvalueToggle) + ToggleRow( + label = "extraBottomSlot", + checked = state.hasExtraBottom, + onToggle = state.onExtraBottomToggle, + ) + ToggleRow( + label = "long title (ellipsis test)", + checked = state.longTitle, + onToggle = state.onLongTitleToggle, + ) + } + } +} + +@Composable +private fun Section(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = label, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } +} + +@Composable +private fun ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TangemTheme.colors2.surface.level2) + .clickable(onClick = onToggle) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = if (checked) "ON" else "OFF", + style = TangemTheme.typography.caption2, + color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt index e808cb171b..dd6c6aba97 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/shimmer/TangemShimmerStory.kt @@ -26,42 +26,48 @@ internal fun TangemShimmerStory(state: TangemShimmerStory, modifier: Modifier = .statusBarsPadding() .fillMaxSize() .background(TangemTheme.colors3.bg.primary) - .verticalScroll(rememberScrollState()) .padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + // Preview stays pinned at the top. ComponentPreview(state = state) - ChipSection(label = "Text style") { - ChipGrid( - items = TextShimmerStyle.entries, - label = { it.chipLabel() }, - isSelected = { it == state.textStyle }, - onSelect = state.onTextStyleChange, - ) - } - ChipSection(label = "Radius") { - ChipGrid( - items = RadiusOption.entries, - label = { it.label }, - isSelected = { it == state.radius }, - onSelect = state.onRadiusChange, - ) - } - ChipSection(label = "Rectangle width") { - ChipGrid( - items = RectangleWidthOption.entries, - label = { it.label }, - isSelected = { it == state.rectangleWidth }, - onSelect = state.onRectangleWidthChange, - ) - } - ChipSection(label = "Rectangle height") { - ChipGrid( - items = RectangleHeightOption.entries, - label = { it.label }, - isSelected = { it == state.rectangleHeight }, - onSelect = state.onRectangleHeightChange, - ) + // Only the controls scroll. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ChipSection(label = "Text style") { + ChipGrid( + items = TextShimmerStyle.entries, + label = { it.chipLabel() }, + isSelected = { it == state.textStyle }, + onSelect = state.onTextStyleChange, + ) + } + ChipSection(label = "Radius") { + ChipGrid( + items = RadiusOption.entries, + label = { it.label }, + isSelected = { it == state.radius }, + onSelect = state.onRadiusChange, + ) + } + ChipSection(label = "Rectangle width") { + ChipGrid( + items = RectangleWidthOption.entries, + label = { it.label }, + isSelected = { it == state.rectangleWidth }, + onSelect = state.onRectangleWidthChange, + ) + } + ChipSection(label = "Rectangle height") { + ChipGrid( + items = RectangleHeightOption.entries, + label = { it.label }, + isSelected = { it == state.rectangleHeight }, + onSelect = state.onRectangleHeightChange, + ) + } } } } 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 e5f0906ef8..29ec749202 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 @@ -4,6 +4,31 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.tangem.feature.tester.presentation.storybook.entity.ButtonsStory +import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory +import com.tangem.feature.tester.presentation.storybook.entity.DsComponentsListStory +import com.tangem.feature.tester.presentation.storybook.entity.NorthernLightsStory +import com.tangem.feature.tester.presentation.storybook.entity.OpportunitiesBGStory +import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeV2Story +import com.tangem.feature.tester.presentation.storybook.entity.TangemButtonStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryBookUM +import com.tangem.feature.tester.presentation.storybook.entity.StoryList +import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemHeaderRowStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemLoaderStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemSegmentedPickerStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory +import com.tangem.feature.tester.presentation.storybook.entity.TypographyStory import com.tangem.feature.tester.presentation.storybook.entity.* import com.tangem.feature.tester.presentation.storybook.page.background.NorthernLightsStory import com.tangem.feature.tester.presentation.storybook.page.badge.TangemBadgeStory @@ -14,7 +39,9 @@ import com.tangem.feature.tester.presentation.storybook.page.deviceicon.DeviceIc import com.tangem.feature.tester.presentation.storybook.page.ds.DsComponentsListStory import com.tangem.feature.tester.presentation.storybook.page.ds.badge.TangemBadgeV2Story import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemButtonStory +import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory +import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory @@ -63,7 +90,9 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemLoaderStory -> TangemLoaderStory(state = storyState) is TangemButtonStory -> TangemButtonStory(state = storyState) is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState) + is TangemRowStory -> TangemRowStory(state = storyState) is TangemShimmerStory -> TangemShimmerStory(state = storyState) + is TangemFadeStory -> TangemFadeStory(state = storyState) } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt index 35258d56cd..a23cd14f49 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -8,12 +8,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.defaultAmount -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.format.bigdecimal.formatStyled -import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.format.bigdecimal.* import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -61,12 +56,12 @@ internal class UpdateStakingNotificationTransformer( iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.common_staking), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Disabled, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = resourceReference(CoreResR.string.staking_notification_network_error_text), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Disabled, ), trailingUM = null, @@ -122,12 +117,12 @@ internal class UpdateStakingNotificationTransformer( iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(id = CoreResR.string.common_staking), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = stakeAvailableSubtitle(availability.option.displayRewardInfo), - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = EarnBlockUM.SubtitleUM.Tone.Disabled, ), trailingUM = EarnBlockUM.TrailingUM.Button( @@ -170,7 +165,7 @@ internal class UpdateStakingNotificationTransformer( iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.staking_enabled), - style = EarnBlockUM.TitleUM.Style.Large, + style = EarnBlockUM.TitleUM.Style.Small, tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = getRewardSubtitle(status, rewardFiatAmount), @@ -257,7 +252,7 @@ internal class UpdateStakingNotificationTransformer( return EarnBlockUM.SubtitleUM.Text( text = text, - style = EarnBlockUM.SubtitleUM.Style.Small, + style = EarnBlockUM.SubtitleUM.Style.Large, tone = if (isAccent) EarnBlockUM.SubtitleUM.Tone.Accent else EarnBlockUM.SubtitleUM.Tone.Disabled, ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 1f5913e64b..ba581e7537 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -2,27 +2,12 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import com.tangem.common.ui.earn.EarnBlock -import com.tangem.common.ui.notifications.notifications -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -31,31 +16,30 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.ui.account.AccountIconUM +import com.tangem.common.ui.earn.EarnBlock import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState +import com.tangem.common.ui.notifications.notifications import com.tangem.core.ui.components.BottomFade import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.components.topFade +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalanceActionsUM import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.ZeroBalanceActionsBlock import com.tangem.features.markets.token.block.TokenMarketBlockComponent @@ -64,6 +48,7 @@ import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.entity.TxHistoryItemsUM import com.tangem.features.txhistory.entity.TxHistoryUM import com.tangem.features.yield.supply.api.YieldSupplyComponent +import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow @@ -72,6 +57,9 @@ import kotlinx.coroutines.flow.StateFlow private val TopBarHeight: Dp = 64.dp private val MarketBlockHorizontalPadding: Dp = 14.dp +private const val TOP_FADE_MID_STOP = 0.8f +private const val TOP_FADE_MID_ALPHA = 0.8f + @Composable internal fun TokenDetailsScreen( tokenDetailsUM: TokenDetailsUM, @@ -84,20 +72,17 @@ internal fun TokenDetailsScreen( val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() } val topBarTotalHeight = TopBarHeight + statusBarHeight + val hazeState = rememberHazeState() val rootBackground = TangemTheme.colors2.surface.level2 var marketBlockHeight by remember { mutableStateOf(0.dp) } val effectiveBottomPadding = marketBlockHeight + TangemTheme.dimens2.x4 - Box( - modifier = modifier - .fillMaxSize() - .background(rootBackground), - ) { + CompositionLocalProvider(LocalHazeState provides hazeState) { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() - .hazeSourceTangem(zIndex = -2f), + .background(rootBackground), ) { TangemPullToRefreshSlidingContainer( config = tokenDetailsUM.pullToRefreshConfig, @@ -115,27 +100,17 @@ internal fun TokenDetailsScreen( modifier = Modifier.fillMaxSize(), ) } + + TokenDetailsTopBar(topAppBarUM = tokenDetailsUM.topAppBarUM) + + if (tokenMarketBlockComponent != null) { + TokenDetailsMarketBlockOverlay( + component = tokenMarketBlockComponent, + onHeightChange = { marketBlockHeight = it }, + ) + } + expressState.bottomSheetSlot?.content(null) } - - TokenDetailsTopBarOverlay(topAppBarUM = tokenDetailsUM.topAppBarUM) - - if (tokenMarketBlockComponent != null) { - TokenDetailsMarketBlockOverlay( - component = tokenMarketBlockComponent, - onHeightChange = { marketBlockHeight = it }, - ) - } - - expressState.bottomSheetSlot?.content(null) - } -} - -@Composable -private fun TokenDetailsTopBarOverlay(topAppBarUM: TokenDetailsTopAppBarUM) { - Box( - modifier = Modifier.background(TangemTheme.colors2.surface.level2), - ) { - TokenDetailsTopBar(topAppBarUM = topAppBarUM) } } @@ -189,7 +164,14 @@ private fun TokenDetailsBody( .padding(start = TangemTheme.dimens2.x4, end = TangemTheme.dimens2.x4, top = TangemTheme.dimens2.x4) LazyColumn( - modifier = modifier, + modifier = modifier + .hazeSourceTangem(state = LocalHazeState.current) + .topFade( + height = topContentPadding, + 0f to rootBackground, + TOP_FADE_MID_STOP to rootBackground.copy(alpha = TOP_FADE_MID_ALPHA), + 1f to Color.Transparent, + ), state = listState, contentPadding = PaddingValues(top = topContentPadding, bottom = bottomContentPadding), ) { @@ -201,14 +183,6 @@ private fun TokenDetailsBody( ) } val balance = tokenDetailsUM.balanceBlockUM - if (balance is TokenDetailsBalanceBlockUM.Content && balance.isBalanceZero) { - item(key = "zero_balance_actions") { - ZeroBalanceActionsBlock( - state = tokenDetailsUM.zeroBalanceActionsUM, - modifier = itemModifier, - ) - } - } notifications( notifications = tokenDetailsUM.notifications, contentColor = rootBackground, @@ -218,12 +192,20 @@ private fun TokenDetailsBody( item(key = "staking_block") { EarnBlock( state = earnBlock, - modifier = itemModifier, + modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x3), ) } } item(key = "yield_supply_block") { - yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2)) + yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x3)) + } + if (balance is TokenDetailsBalanceBlockUM.Content && balance.isBalanceZero) { + item(key = "zero_balance_actions") { + ZeroBalanceActionsBlock( + state = tokenDetailsUM.zeroBalanceActionsUM, + modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2), + ) + } } with(expressTransactionsComponent) { expressTransactionsContent( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt index d49d928013..a25b282c16 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt @@ -1,8 +1,10 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import android.content.res.Configuration +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.appendInlineContent @@ -11,6 +13,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.* @@ -30,6 +33,8 @@ import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.components.haze.ProvideHaze +import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.ds.image.TangemDeviceIcon import com.tangem.core.ui.ds.topbar.TangemTopBar @@ -44,19 +49,22 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.* import com.tangem.core.ui.R as CoreUiR @Composable internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: Modifier = Modifier) { + val actionModifier = Modifier + .clip(CircleShape) + .hazeEffectTangem { blurRadius = ACTION_BLUR_RADIUS } TangemTopBar( modifier = modifier.statusBarsPadding(), startContent = { TangemTopBarActionContent( + modifier = actionModifier, actionUM = TangemTopBarActionUM( iconRes = R.drawable.ic_arrow_back_28, onClick = topAppBarUM.onBackClick, - ghostModeProgress = 1f, ), ) }, @@ -65,10 +73,10 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) } Box { TangemTopBarActionContent( + modifier = actionModifier, actionUM = TangemTopBarActionUM( iconRes = CoreUiR.drawable.ic_more_default_24, onClick = { isDropdownMenuShown = true }, - ghostModeProgress = 1f, ), ) TangemDropdownMenu( @@ -100,8 +108,8 @@ internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: TokenDetailsTitle(titleState = topAppBarUM.titleState) Text( text = topAppBarUM.subtitle.resolveAnnotatedReference(), - color = TangemTheme.colors2.text.neutral.secondary, - style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.tertiary, + style = TangemTheme.typography2.captionSemibold12, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -138,11 +146,7 @@ private fun TokenDetailsTitle(titleState: TitleState) { AdaptiveTokenWithSecondaryRow( tokenName = titleState.tokenName, secondaryName = titleState.walletName, - template = stringResourceSafe( - id = CoreUiR.string.token_details_toolbar_title_token_in_wallet, - titleState.tokenName, - titleState.walletName, - ), + template = formattedTitleTemplate(CoreUiR.string.token_details_toolbar_title_token_in_wallet), appearance = appearance, icon = { TangemDeviceIcon( @@ -153,21 +157,16 @@ private fun TokenDetailsTitle(titleState: TitleState) { ) } is TitleState.WithAccount -> { - val accountNameStr = titleState.accountName.resolveAnnotatedReference().toString() AdaptiveTokenWithSecondaryRow( tokenName = titleState.tokenName, - secondaryName = accountNameStr, - template = stringResourceSafe( - id = CoreUiR.string.token_details_toolbar_title_token_in_account, - titleState.tokenName, - accountNameStr, - ), + secondaryName = titleState.accountName.resolveAnnotatedReference().toString(), + template = formattedTitleTemplate(CoreUiR.string.token_details_toolbar_title_token_in_account), appearance = appearance, icon = { AccountIcon( name = titleState.accountName, icon = titleState.accountIconUM, - size = AccountIconSize.ExtraSmall, + size = AccountIconSize.RedesignExtraSmall, ) }, ) @@ -178,9 +177,12 @@ private fun TokenDetailsTitle(titleState: TitleState) { /** * Adaptive title for [TitleState.WithAccount] / [TitleState.WithWallet]. * - * Phrase template carries the [IMAGE_PLACEHOLDER] marker — translator decides where - * the icon sits (e.g. "Tether in [⭐] Portfolio" or "Tether in My Wallet [⭐]"). - * RTL is handled by BiDi inside the single [Text]. + * Raw template carries [TOKEN_MARKER] / [SECONDARY_MARKER] for the names and + * [IMAGE_PLACEHOLDER] for the icon — translator decides where each sits + * (e.g. "Tether in [⭐] Portfolio" or "Tether in My Wallet [⭐]"). Anything outside + * the markers (connecting words, punctuation) is painted as tertiary text — no + * locale-specific substring matching required. RTL is handled by BiDi inside the + * single [Text]. * * Width-driven cascade: * 1–2. Full phrase as single [Text] with inline icon; [TextOverflow.Ellipsis] @@ -201,7 +203,7 @@ private fun AdaptiveTokenWithSecondaryRow( appearance: TitleAppearance, icon: @Composable () -> Unit, ) { - val (beforeIcon, afterIcon) = remember(template) { splitTemplate(template) } + val segments = remember(template) { parseTemplate(template) } val inlineContent = rememberIconInlineContent(appearance.iconSize, icon) BoxWithConstraints( @@ -209,16 +211,16 @@ private fun AdaptiveTokenWithSecondaryRow( contentAlignment = Alignment.Center, ) { val isFullTextShown = rememberShouldShowFullText( - beforeIcon = beforeIcon, - afterIcon = afterIcon, - secondaryName = secondaryName, + segments = segments, + tokenName = tokenName, appearance = appearance, maxWidthPx = constraints.maxWidth, ) if (isFullTextShown) { FullPhraseTitle( - beforeIcon = beforeIcon, - afterIcon = afterIcon, + segments = segments, + tokenName = tokenName, + secondaryName = secondaryName, style = appearance.style, inlineContent = inlineContent, ) @@ -232,17 +234,25 @@ private fun AdaptiveTokenWithSecondaryRow( } } -private fun splitTemplate(template: String): Pair { - val parts = template.split(IMAGE_PLACEHOLDER, limit = 2) - return if (parts.size == 2) parts[0] to parts[1] else template to "" -} +/** + * Returns the title template with its `%1$s` / `%2$s` placeholders replaced by [TOKEN_MARKER] / + * [SECONDARY_MARKER] sentinels (and `%%image%%` collapsed to [IMAGE_PLACEHOLDER]). + * + * Feeding the markers in as format args lets [String.format] resolve all escaping for us, so + * [parseTemplate] sees a clean string and the real names stay un-substituted until render time. + */ +@Composable +private fun formattedTitleTemplate(@StringRes id: Int): String = stringResourceSafe(id, TOKEN_MARKER, SECONDARY_MARKER) @Composable -private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit): Map { +private fun rememberIconInlineContent( + iconSize: Dp, + icon: @Composable () -> Unit, +): ImmutableMap { val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() } val currentIcon by rememberUpdatedState(icon) return remember(iconSizeSp) { - mapOf( + persistentMapOf( ICON_INLINE_ID to InlineTextContent( placeholder = Placeholder( width = iconSizeSp, @@ -261,23 +271,29 @@ private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit @Composable private fun rememberShouldShowFullText( - beforeIcon: String, - afterIcon: String, - secondaryName: String, + segments: ImmutableList, + tokenName: String, appearance: TitleAppearance, maxWidthPx: Int, ): Boolean { val measurer = rememberTextMeasurer() val density = LocalDensity.current - return remember(beforeIcon, afterIcon, secondaryName, maxWidthPx, appearance, density) { + return remember(segments, tokenName, maxWidthPx, appearance, density) { if (maxWidthPx <= 0) return@remember true - val fullTextWidthPx = measurer - .measure(text = beforeIcon + afterIcon, style = appearance.style, softWrap = false) - .size.width - val secondaryWidthPx = measurer - .measure(text = secondaryName, style = appearance.style, softWrap = false) - .size.width - val staticWidthPx = (fullTextWidthPx - secondaryWidthPx).coerceAtLeast(0) + val staticText = buildString { + for (segment in segments) { + when (segment) { + TitleSegment.Token -> append(tokenName) + is TitleSegment.Plain -> append(segment.text) + TitleSegment.Secondary, TitleSegment.Image -> Unit + } + } + } + val staticWidthPx = if (staticText.isEmpty()) { + 0 + } else { + measurer.measure(text = staticText, style = appearance.style, softWrap = false).size.width + } val iconReservePx = with(density) { (appearance.iconSize + appearance.spacing * 2).toPx() }.toInt() @@ -288,16 +304,25 @@ private fun rememberShouldShowFullText( @Composable private fun FullPhraseTitle( - beforeIcon: String, - afterIcon: String, + segments: ImmutableList, + tokenName: String, + secondaryName: String, style: TextStyle, - inlineContent: Map, + inlineContent: ImmutableMap, ) { - val fullText = remember(beforeIcon, afterIcon) { + val tertiaryColor = TangemTheme.colors2.text.neutral.tertiary + val fullText = remember(segments, tokenName, secondaryName, tertiaryColor) { buildAnnotatedString { - append(beforeIcon) - appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER) - append(afterIcon) + for (segment in segments) { + when (segment) { + TitleSegment.Token -> append(tokenName) + TitleSegment.Secondary -> append(secondaryName) + TitleSegment.Image -> appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER) + is TitleSegment.Plain -> withStyle(SpanStyle(color = tertiaryColor)) { + append(segment.text) + } + } + } } } Text( @@ -311,6 +336,47 @@ private fun FullPhraseTitle( ) } +private sealed interface TitleSegment { + data object Token : TitleSegment + data object Secondary : TitleSegment + data object Image : TitleSegment + data class Plain(val text: String) : TitleSegment +} + +/** + * Splits the [String.format]ed template (see [formattedTitleTemplate]) into ordered segments. + * + * Markers appear in their resolved form: [TOKEN_MARKER] / [SECONDARY_MARKER] for the names and + * [IMAGE_PLACEHOLDER] for the icon (escaping was already collapsed by [String.format]). Anything + * else is plain connecting text painted as tertiary. + */ +private fun parseTemplate(template: String): ImmutableList { + val segments = mutableListOf() + val plain = StringBuilder() + + fun flushPlain() { + if (plain.isNotEmpty()) { + segments += TitleSegment.Plain(plain.toString()) + plain.clear() + } + } + + var i = 0 + while (i < template.length) { + val marker = MARKERS.firstOrNull { (text, _) -> template.startsWith(text, i) } + if (marker != null) { + flushPlain() + segments += marker.second + i += marker.first.length + } else { + plain.append(template[i]) + i++ + } + } + flushPlain() + return segments.toImmutableList() +} + @Composable private fun FallbackTokenWithIconTitle(tokenName: String, appearance: TitleAppearance, icon: @Composable () -> Unit) { Row( @@ -342,7 +408,17 @@ private data class TitleAppearance( private const val ICON_INLINE_ID = "account_icon" private const val IMAGE_PLACEHOLDER = "%image%" +private const val TOKEN_MARKER = "%1\$s" +private const val SECONDARY_MARKER = "%2\$s" +// Order matters: longer/overlapping markers must be tried first by [parseTemplate]. +private val MARKERS: List> = listOf( + IMAGE_PLACEHOLDER to TitleSegment.Image, + TOKEN_MARKER to TitleSegment.Token, + SECONDARY_MARKER to TitleSegment.Secondary, +) + +private val ACTION_BLUR_RADIUS = 8.dp private val MIN_TITLE_FONT_SIZE = 12.sp private val MAX_TITLE_FONT_SIZE = 16.sp private val MIN_SECONDARY_NAME_WIDTH = 48.dp @@ -355,20 +431,22 @@ private fun TokenDetailsTopBar_Preview( @PreviewParameter(TokenDetailsTopBarPreviewProvider::class) titleState: TitleState, ) { TangemThemePreviewRedesign { - TokenDetailsTopBar( - topAppBarUM = TokenDetailsTopAppBarUM( - titleState = titleState, - subtitle = stringReference("ERC-20 in Ethereum network"), - onBackClick = {}, - menuItems = persistentListOf( - TangemDropdownMenuItem( - title = stringReference("Hide Token"), - textColor = themedColor { TangemTheme.colors.text.warning }, - onClick = {}, + ProvideHaze { + TokenDetailsTopBar( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = titleState, + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf( + TangemDropdownMenuItem( + title = stringReference("Hide Token"), + textColor = themedColor { TangemTheme.colors.text.warning }, + onClick = {}, + ), ), ), - ), - ) + ) + } } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index 12fb6fcc26..ad37bfa321 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -4,16 +4,10 @@ import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -102,8 +96,7 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd targetState = state.tokenBalanceTypeUM.type, label = "Token balance type", ) { currentType -> - val tokenBalanceTypeUM = state.tokenBalanceTypeUM - when (tokenBalanceTypeUM) { + when (val tokenBalanceTypeUM = state.tokenBalanceTypeUM) { is TokenBalanceTypeUM.Multiple -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), @@ -111,20 +104,20 @@ private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content, isBalanceHidd ) { Text( text = currentType.text.resolveReference(), - style = TangemTheme.typography2.calloutSemibold15, - color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors2.text.neutral.primary, ) Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_sort_24), contentDescription = null, tint = TangemTheme.colors2.graphic.neutral.secondary, - modifier = Modifier.size(TangemTheme.dimens2.x4), + modifier = Modifier.size(TangemTheme.dimens2.x5), ) } TokenBalanceTypeUM.Single -> Text( text = currentType.text.resolveReference(), - style = TangemTheme.typography2.calloutSemibold15, - color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors2.text.neutral.primary, ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/ZeroBalanceActionsBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/ZeroBalanceActionsBlock.kt index 58173be9e0..624dc99bab 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/ZeroBalanceActionsBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/ZeroBalanceActionsBlock.kt @@ -1,11 +1,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components import androidx.annotation.DrawableRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -26,7 +22,7 @@ internal fun ZeroBalanceActionsBlock(state: ZeroBalanceActionsUM, modifier: Modi Column( modifier = modifier .fillMaxWidth() - .padding(bottom = TangemTheme.dimens2.x10), + .padding(bottom = TangemTheme.dimens2.x6), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), ) { ActionRow( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt index cbe63f3861..9ad9b59660 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt @@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.wallet.presentation.wallet.state.model.* +import com.tangem.feature.wallet.presentation.wallet.state.utils.disableButtons import com.tangem.feature.wallet.presentation.wallet.state.utils.enableButtons import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate @@ -34,11 +35,18 @@ internal class SetRefreshStateTransformer( override fun transform(walletUM: WalletUM): WalletUM { return when (walletUM) { - is WalletUM.Content -> walletUM.copy( - pullToRefreshConfig = walletUM.pullToRefreshConfig.toUpdatedState(isRefreshing), - tokensListUM = walletUM.tokensListUM.toUpdatedState(), - buttons = walletUM.enableButtons(), - ) + is WalletUM.Content -> { + val tokensListUM = walletUM.tokensListUM.toUpdatedState() + walletUM.copy( + pullToRefreshConfig = walletUM.pullToRefreshConfig.toUpdatedState(isRefreshing), + tokensListUM = tokensListUM, + buttons = if (tokensListUM is WalletTokensListUM.Empty) { + walletUM.disableButtons() + } else { + walletUM.enableButtons() + }, + ) + } is WalletUM.Locked -> walletUM } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt index 524890d9ec..17f7d3238e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt @@ -9,6 +9,7 @@ import com.tangem.domain.staking.model.StakingAvailability import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.state.model.* import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.* +import com.tangem.feature.wallet.presentation.wallet.state.utils.disableButtons import com.tangem.feature.wallet.presentation.wallet.state.utils.enableButtons import com.tangem.features.tangempay.entity.TangemPayMainUM import com.tangem.utils.logging.TangemLogger @@ -60,11 +61,16 @@ internal class SetTokenListTransformer( override fun transform(walletUM: WalletUM): WalletUM { return when (walletUM) { is WalletUM.Content -> { + val tokensListUM = toLoadedState() walletUM.copy( walletsBalanceUM = walletUM.walletsBalanceUM.toLoadedState2(), tangemPayMainUM = walletUM.tangemPayMainUM.toLoadedState(), - tokensListUM = toLoadedState(), - buttons = walletUM.enableButtons(), + tokensListUM = tokensListUM, + buttons = if (tokensListUM is WalletTokensListUM.Empty) { + walletUM.disableButtons() + } else { + walletUM.enableButtons() + }, ) } is WalletUM.Locked -> { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 62e8944f16..30229f4855 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -59,8 +59,8 @@ import com.tangem.core.ui.components.sheetscaffold.* import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior -import com.tangem.core.ui.extensions.softLayerShadow import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.softLayerShadow import com.tangem.core.ui.res.* import com.tangem.core.ui.utils.TangemSharedTransitionLayout import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData @@ -176,6 +176,7 @@ private fun WalletContent2( state.wallets2.getOrNull(state.selectedWalletIndex)?.pullToRefreshConfig, ) } + var subtitleBottom by remember { mutableStateOf(0.dp) } BaseScaffoldWithMarkets( modifier = modifier, @@ -240,6 +241,7 @@ private fun WalletContent2( pullToRefreshState = pullToRefreshState, pullToRefreshConfig = pullToRefreshConfig, behavior = behavior, + topOffset = subtitleBottom + TangemTheme.dimens2.x2, ) val overlay = TangemTheme.colors2.overlay.overlayPrimary @@ -306,6 +308,9 @@ private fun WalletContent2( walletBalanceUM = currentWallet.walletsBalanceUM, buttons = currentWallet.buttons, isBalanceHidden = state.isHidingMode, + onSubtitleBottomChange = { newValue -> + if (newValue > subtitleBottom) subtitleBottom = maxOf(subtitleBottom, newValue) + }, ) }, body = { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt index 9e1eef6fba..ac978268db 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt @@ -16,11 +16,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.TextShimmer @@ -32,11 +36,7 @@ import com.tangem.core.ui.ds.placeholder.TextPlaceholder import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resolveAnnotatedReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.MainScreenTestTags @@ -58,10 +58,12 @@ internal fun WalletBalance( buttons: ImmutableList, isBalanceHidden: Boolean, modifier: Modifier = Modifier, + onSubtitleBottomChange: (Dp) -> Unit = {}, ) { val collapsedFraction = behavior.state.collapsedFraction val alpha = 1f - collapsedFraction val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE) + val density = LocalDensity.current Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -85,7 +87,15 @@ internal fun WalletBalance( isBalanceHidden = isBalanceHidden, ) SpacerH(TangemTheme.dimens2.x3) - SubtitleRow(walletBalanceUM = walletBalanceUM) + Box( + modifier = Modifier.onGloballyPositioned { coordinates -> + val rawBottomPx = coordinates.boundsInRoot().bottom + if (rawBottomPx <= 0f) return@onGloballyPositioned + onSubtitleBottomChange(with(density) { rawBottomPx.toDp() }) + }, + ) { + SubtitleRow(walletBalanceUM = walletBalanceUM) + } } SpacerH(TangemTheme.dimens2.x2) ActionButtons(buttons) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt index ec7c7888f7..328070e017 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletPagerIndicator.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.containers.pullToRefresh.getPullToRefreshIndicatorOffset import com.tangem.core.ui.ds.TangemPagerIndicator @@ -23,7 +24,6 @@ import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior private const val MIN_SCALE = 0.75f private const val MAX_SCALE = 1f -private const val WALLET_INDICATOR_OFFSET = 0.6f @Composable internal fun WalletPagerIndicator( @@ -31,6 +31,7 @@ internal fun WalletPagerIndicator( behavior: TangemCollapsingAppBarBehavior, pullToRefreshConfig: PullToRefreshConfig?, pullToRefreshState: PullToRefreshState, + topOffset: Dp, ) { val collapsedFraction = behavior.state.collapsedFraction val alpha = MAX_SCALE - collapsedFraction @@ -43,7 +44,6 @@ internal fun WalletPagerIndicator( pullToRefreshConfig = pullToRefreshConfig, pullToRefreshState = pullToRefreshState, ) - val padding = height * WALLET_INDICATOR_OFFSET AnimatedVisibility( visible = pagerState.pageCount > 1, @@ -64,7 +64,7 @@ internal fun WalletPagerIndicator( TangemPagerIndicator( pagerState = pagerState, modifier = Modifier - .padding(top = padding) + .padding(top = topOffset) .scale(scaleY = 1f, scaleX = scale), ) } diff --git a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformerTest.kt b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformerTest.kt new file mode 100644 index 0000000000..e4166ddeed --- /dev/null +++ b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformerTest.kt @@ -0,0 +1,188 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.tokens.TokenConverterParams +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.utils.lceError +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account.CryptoPortfolio.Companion.createMainAccount +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents +import com.tangem.feature.wallet.presentation.wallet.state.model.* +import com.tangem.features.tangempay.entity.TangemPayMainUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import java.math.BigDecimal + +class SetTokenListTransformerTest { + + private val userWalletId = UserWalletId("00") + + @Test + fun `GIVEN empty account list WHEN transform THEN tokens are Empty and buttons disabled`() { + val transformer = createTransformer(accountList = emptyAccountList()) + + val result = transformer.transform(walletUM(buttonEnabled = true)) as WalletUM.Content + + assertThat(result.tokensListUM).isInstanceOf(WalletTokensListUM.Empty::class.java) + assertThat(result.buttons).hasSize(1) + assertThat(result.buttons.single().isEnabled).isFalse() + } + + @Test + fun `GIVEN non-empty account list WHEN transform THEN tokens not Empty and buttons enabled`() { + val transformer = createTransformer(accountList = nonEmptyAccountList()) + + val result = transformer.transform(walletUM(buttonEnabled = false)) as WalletUM.Content + + assertThat(result.tokensListUM).isNotInstanceOf(WalletTokensListUM.Empty::class.java) + assertThat(result.buttons).hasSize(1) + assertThat(result.buttons.single().isEnabled).isTrue() + } + + private fun createTransformer(accountList: AccountStatusList): SetTokenListTransformer { + val userWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + return SetTokenListTransformer( + params = TokenConverterParams.Account( + accountList = accountList, + expandedAccounts = emptySet(), + ), + userWallet = userWallet, + appCurrency = AppCurrency.Default, + clickIntents = mockk(relaxed = true), + shouldShowMainPromo = false, + isAccountsModeEnabled = false, + isRedesignEnabled = true, + isAddAndManageTokensEnabled = false, + ) + } + + private fun emptyAccountList(): AccountStatusList = AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(mainCryptoPortfolioStatus(tokenList = TokenList.Empty)), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + + private fun nonEmptyAccountList(): AccountStatusList { + val token = createToken() + val tokenList = TokenList.Ungrouped( + currencies = listOf(createLoadedStatus(token)), + totalFiatBalance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL), + sortedBy = TokensSortType.NONE, + ) + return AccountStatusList( + userWalletId = userWalletId, + accountStatuses = listOf(mainCryptoPortfolioStatus(tokenList = tokenList)), + totalAccounts = 1, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loading, + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + } + + private fun mainCryptoPortfolioStatus(tokenList: TokenList): AccountStatus.CryptoPortfolio = + AccountStatus.CryptoPortfolio( + account = createMainAccount(userWalletId), + tokenList = tokenList, + priceChangeLce = Unit.lceError(), + ) + + private fun walletUM(buttonEnabled: Boolean): WalletUM.Content = WalletUM.Content( + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + walletsBalanceUM = WalletBalanceUM.Loading( + id = userWalletId, + name = "test", + deviceIcon = DeviceIconUM.Mobile, + ), + buttons = persistentListOf( + TangemButtonUM( + text = stringReference(value = "Buy"), + type = TangemButtonType.Secondary, + onClick = {}, + isEnabled = buttonEnabled, + ), + ), + notifications = persistentListOf(), + notificationsCarousel = persistentListOf(), + tokensListUM = WalletTokensListUM.Loading, + nftState = WalletNFTItemUM.Hidden, + type = WalletType.Hot, + tangemPayMainUM = TangemPayMainUM.Empty, + ) + + private fun createToken(): CryptoCurrency.Token { + val network = Network( + id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None), + name = "ethereum", + currencySymbol = "ETH", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = false, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"), + suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0xABCDEF"), + ), + network = network, + name = "Token", + symbol = "TKN", + decimals = 18, + iconUrl = null, + isCustom = false, + contractAddress = "0xABCDEF", + ) + } + + private fun createLoadedStatus(token: CryptoCurrency.Token): CryptoCurrencyStatus { + val networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "addr", + type = NetworkAddress.Address.Type.Primary, + ), + ) + val value = CryptoCurrencyStatus.Loaded( + amount = BigDecimal.ONE, + fiatAmount = BigDecimal.ZERO, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = networkAddress, + sources = CryptoCurrencyStatus.Sources(), + ) + return CryptoCurrencyStatus(currency = token, value = value) + } +} \ No newline at end of file diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index e1341f668a..a837781837 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -1,9 +1,42 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) id("configuration") } android { namespace = "com.tangem.lib.auth" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + /** Core */ + implementation(projects.core.utils) + implementation(projects.core.configToggles) + + /** Tangem libraries */ + implementation(tangemDeps.card.core) + + /** Firebase */ + implementation(platform(deps.firebase.bom)) + implementation(deps.firebase.crashlytics) + + /** Other */ + implementation(deps.arrow.core) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(deps.test.mockk) } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/AuthFeatureToggles.kt b/libs/auth/src/main/java/com/tangem/lib/auth/AuthFeatureToggles.kt new file mode 100644 index 0000000000..a2eb2b378b --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/AuthFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.lib.auth + +interface AuthFeatureToggles { + val isBackendAuthenticationEnabled: Boolean +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/DefaultAuthFeatureToggles.kt b/libs/auth/src/main/java/com/tangem/lib/auth/DefaultAuthFeatureToggles.kt new file mode 100644 index 0000000000..a7151aec10 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/DefaultAuthFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.lib.auth + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import javax.inject.Inject + +internal class DefaultAuthFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : AuthFeatureToggles { + + override val isBackendAuthenticationEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15438_BACKEND_AUTHENTICATION_ENABLED) +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeyManager.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeyManager.kt new file mode 100644 index 0000000000..23e34014bb --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeyManager.kt @@ -0,0 +1,27 @@ +package com.tangem.lib.auth.devicekey + +import arrow.core.Option + +/** + * Manages a device-bound secp256r1 keypair in Android Keystore (TEE/StrongBox). + * The private key never leaves the secure hardware. + */ +interface DeviceKeyManager { + + /** + * Ensures the device keypair exists. Generates one if missing. + * Never throws — generation failures are logged and reported via the return value. + * @return `true` if a new keypair was generated, `false` if it already existed or generation failed + */ + suspend fun generateIfMissing(): Boolean + + /** Raw uncompressed public key (0x04 || x || y), or [arrow.core.None] if it cannot be read. */ + suspend fun getPublicKey(): Option + + /** + * Signs [data] with SHA256withECDSA using the device private key. + * @return raw 64-byte signature (r || s), each component zero-padded to 32 bytes + * @throws DeviceKeySigningException if signing fails + */ + suspend fun sign(data: ByteArray): ByteArray +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeySigningException.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeySigningException.kt new file mode 100644 index 0000000000..94575b72c0 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeySigningException.kt @@ -0,0 +1,3 @@ +package com.tangem.lib.auth.devicekey + +class DeviceKeySigningException(message: String, cause: Throwable? = null) : Exception(message, cause) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/di/DeviceKeyModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/di/DeviceKeyModule.kt new file mode 100644 index 0000000000..24b93f68da --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/di/DeviceKeyModule.kt @@ -0,0 +1,38 @@ +package com.tangem.lib.auth.devicekey.di + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.tangem.lib.auth.AuthFeatureToggles +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.devicekey.internal.DefaultDeviceKeyManager +import com.tangem.lib.auth.devicekey.internal.DisabledDeviceKeyManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.security.KeyStore +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object DeviceKeyModule { + + @Provides + @Singleton + fun provideDeviceKeyManager( + authFeatureToggles: AuthFeatureToggles, + dispatchers: CoroutineDispatcherProvider, + ): DeviceKeyManager { + if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledDeviceKeyManager + + return runCatching { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + DefaultDeviceKeyManager(keyStore, dispatchers) + }.getOrElse { e -> + TangemLogger.e("Failed to init AndroidKeyStore, falling back to disabled DeviceKeyManager", e) + FirebaseCrashlytics.getInstance().recordException(e) + DisabledDeviceKeyManager + } + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt new file mode 100644 index 0000000000..79520eb33a --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt @@ -0,0 +1,125 @@ +package com.tangem.lib.auth.devicekey.internal + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import arrow.core.None +import arrow.core.Option +import com.tangem.crypto.Secp256r1 +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.devicekey.DeviceKeySigningException +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import java.security.spec.ECGenParameterSpec + +internal class DefaultDeviceKeyManager( + private val keyStore: KeyStore, + private val dispatchers: CoroutineDispatcherProvider, +) : DeviceKeyManager { + + override suspend fun generateIfMissing(): Boolean = withContext(dispatchers.io) { + if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false + + try { + generateKey() + TangemLogger.i("Device key generated") + true + } catch (e: Exception) { + TangemLogger.e("Failed to generate device key", e) + false + } + } + + override suspend fun getPublicKey(): Option = withContext(dispatchers.io) { + Option.catch( + recover = { e -> + TangemLogger.e("Failed to get device public key", e) + None + }, + f = ::getPublicKeyBytes, + ) + } + + override suspend fun sign(data: ByteArray): ByteArray = withContext(dispatchers.io) { + try { + val privateKey = keyStore.getKey(KEY_ALIAS, null) + ?: throw DeviceKeySigningException("Device key not found") + + val signature = Signature.getInstance(SIGNATURE_ALGORITHM).apply { + initSign(privateKey as java.security.PrivateKey) + update(data) + } + + val derSignature = signature.sign() + Secp256r1.toByte64(derSignature) + } catch (e: DeviceKeySigningException) { + TangemLogger.e("Device key signing failed", e) + throw e + } catch (e: Exception) { + TangemLogger.e("Device key signing failed", e) + throw DeviceKeySigningException("Signing failed", e) + } + } + + private fun generateKey() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + initAndGenerateKeyPair(strongBox = true) + } catch (e: Exception) { + TangemLogger.i("StrongBox unavailable, falling back to TEE", e) + initAndGenerateKeyPair(strongBox = false) + } + } else { + initAndGenerateKeyPair(strongBox = false) + } + } + + private fun initAndGenerateKeyPair(strongBox: Boolean) { + val spec = buildKeyGenSpec(strongBox) + val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER) + generator.initialize(spec) + generator.generateKeyPair() + } + + private fun buildKeyGenSpec(strongBox: Boolean): KeyGenParameterSpec { + val builder = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY, + ) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setDigests(KeyProperties.DIGEST_SHA256) + .setUserAuthenticationRequired(false) + + if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + + return builder.build() + } + + private fun getPublicKeyBytes(): ByteArray { + val cert = checkNotNull(keyStore.getCertificate(KEY_ALIAS)) { "Device key not found" } + + val encoded = cert.publicKey.encoded + check(encoded.size >= EC_UNCOMPRESSED_POINT_SIZE) { + "Invalid encoded public key: expected at least $EC_UNCOMPRESSED_POINT_SIZE bytes, got ${encoded.size}" + } + val point = encoded.copyOfRange(encoded.size - EC_UNCOMPRESSED_POINT_SIZE, encoded.size) + check(point[0] == UNCOMPRESSED_POINT_PREFIX) { + "Invalid EC public key: expected uncompressed point prefix 0x04, got 0x${"%02x".format(point[0])}" + } + return point + } + + private companion object { + const val KEYSTORE_PROVIDER = "AndroidKeyStore" + const val KEY_ALIAS = "tangem_device_key" + const val SIGNATURE_ALGORITHM = "SHA256withECDSA" + const val EC_UNCOMPRESSED_POINT_SIZE = 65 + const val UNCOMPRESSED_POINT_PREFIX = 0x04.toByte() + } +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt new file mode 100644 index 0000000000..6ba337519c --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt @@ -0,0 +1,16 @@ +package com.tangem.lib.auth.devicekey.internal + +import arrow.core.None +import arrow.core.Option +import com.tangem.lib.auth.devicekey.DeviceKeyManager +import com.tangem.lib.auth.devicekey.DeviceKeySigningException + +internal object DisabledDeviceKeyManager : DeviceKeyManager { + + override suspend fun generateIfMissing(): Boolean = false + + override suspend fun getPublicKey(): Option = None + + override suspend fun sign(data: ByteArray): ByteArray = + throw DeviceKeySigningException("DeviceKeyManager is disabled: backend authentication feature toggle is off") +} \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthFeatureTogglesModule.kt b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthFeatureTogglesModule.kt new file mode 100644 index 0000000000..9a31739046 --- /dev/null +++ b/libs/auth/src/main/java/com/tangem/lib/auth/di/AuthFeatureTogglesModule.kt @@ -0,0 +1,21 @@ +package com.tangem.lib.auth.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.lib.auth.AuthFeatureToggles +import com.tangem.lib.auth.DefaultAuthFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AuthFeatureTogglesModule { + + @Provides + @Singleton + fun provideAuthFeatureToggles(featureTogglesManager: FeatureTogglesManager): AuthFeatureToggles { + return DefaultAuthFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManagerTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManagerTest.kt new file mode 100644 index 0000000000..8f7cf3187d --- /dev/null +++ b/libs/auth/src/test/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManagerTest.kt @@ -0,0 +1,175 @@ +package com.tangem.lib.auth.devicekey.internal + +import arrow.core.None +import com.google.common.truth.Truth.assertThat +import com.tangem.lib.auth.devicekey.DeviceKeySigningException +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertThrows +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.cert.Certificate + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultDeviceKeyManagerTest { + + private val keyStore: KeyStore = mockk(relaxed = true) + private val dispatchers = TestingCoroutineDispatcherProvider() + private val manager: DefaultDeviceKeyManager = DefaultDeviceKeyManager(keyStore, dispatchers) + + @BeforeEach + fun setup() { + clearMocks(keyStore) + } + + @AfterEach + fun teardown() { + unmockkAll() + } + + @Test + fun `generateIfMissing returns false when key already exists`() = runTest { + every { keyStore.containsAlias(KEY_ALIAS) } returns true + + val result = manager.generateIfMissing() + + assertThat(result).isFalse() + } + + @Test + fun `generateIfMissing returns false when generation fails`() = runTest { + every { keyStore.containsAlias(KEY_ALIAS) } returns false + + val keyPairGenerator = mockk(relaxed = true) + every { keyPairGenerator.generateKeyPair() } throws RuntimeException("keystore unavailable") + + mockkStatic(KeyPairGenerator::class) + every { KeyPairGenerator.getInstance("EC", "AndroidKeyStore") } returns keyPairGenerator + + val result = manager.generateIfMissing() + + assertThat(result).isFalse() + } + + @Test + fun `getPublicKey returns last 65 bytes from encoded key`() = runTest { + val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x04 } + val x509Header = ByteArray(26) { 0x30 } + val encoded = x509Header + rawPoint + + val publicKey = mockk() + every { publicKey.encoded } returns encoded + + val cert = mockk() + every { cert.publicKey } returns publicKey + every { keyStore.getCertificate(KEY_ALIAS) } returns cert + + val result = manager.getPublicKey() + + assertThat(result.getOrNull()).isEqualTo(rawPoint) + } + + @Test + fun `getPublicKey returns None when certificate not found`() = runTest { + every { keyStore.getCertificate(KEY_ALIAS) } returns null + + val result = manager.getPublicKey() + + assertThat(result).isEqualTo(None) + } + + @Test + fun `getPublicKey returns None when point prefix is not uncompressed`() = runTest { + val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x02 } + val x509Header = ByteArray(26) { 0x30 } + val encoded = x509Header + rawPoint + + val publicKey = mockk() + every { publicKey.encoded } returns encoded + + val cert = mockk() + every { cert.publicKey } returns publicKey + every { keyStore.getCertificate(KEY_ALIAS) } returns cert + + val result = manager.getPublicKey() + + assertThat(result).isEqualTo(None) + } + + @Test + fun `sign returns raw 64-byte signature`() = runTest { + val data = "test data".toByteArray() + val r = ByteArray(32) { 0x01 } + val s = ByteArray(32) { 0x02 } + val derSignature = buildDer(r, s) + + val privateKey = mockk() + every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey + + val javaSig = mockk() + every { javaSig.initSign(privateKey) } returns Unit + every { javaSig.update(data) } returns Unit + every { javaSig.sign() } returns derSignature + + mockkSignatureGetInstance(javaSig) + + val result = manager.sign(data) + + assertThat(result).hasLength(64) + assertThat(result.copyOfRange(0, 32)).isEqualTo(r) + assertThat(result.copyOfRange(32, 64)).isEqualTo(s) + } + + @Test + fun `sign throws DeviceKeySigningException when key not found`() = runTest { + every { keyStore.getKey(KEY_ALIAS, null) } returns null + + val exception = assertThrows { + manager.sign("data".toByteArray()) + } + assertThat(exception.message).contains("Device key not found") + } + + @Test + fun `sign wraps unexpected exception in DeviceKeySigningException`() = runTest { + val privateKey = mockk() + every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey + + val javaSig = mockk() + every { javaSig.initSign(privateKey) } throws RuntimeException("hardware error") + + mockkSignatureGetInstance(javaSig) + + val exception = assertThrows { + manager.sign("data".toByteArray()) + } + assertThat(exception.message).isEqualTo("Signing failed") + assertThat(exception.cause).isInstanceOf(RuntimeException::class.java) + } + + private fun mockkSignatureGetInstance(mock: java.security.Signature) { + io.mockk.mockkStatic(java.security.Signature::class) + every { java.security.Signature.getInstance("SHA256withECDSA") } returns mock + } + + private fun buildDer(r: ByteArray, s: ByteArray): ByteArray { + val rTlv = byteArrayOf(0x02, r.size.toByte()) + r + val sTlv = byteArrayOf(0x02, s.size.toByte()) + s + val body = rTlv + sTlv + return byteArrayOf(0x30, body.size.toByte()) + body + } + + private companion object { + const val KEY_ALIAS = "tangem_device_key" + } +} \ No newline at end of file