Updated on 2026-08-14
This commit is contained in:
commit
63ab7f4037
42 changed files with 2532 additions and 307 deletions
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -93,6 +93,39 @@ fun Modifier.topFade(
|
|||
solidStop = solidStop,
|
||||
)
|
||||
|
||||
/**
|
||||
* Draws a vertical gradient fade over the top edge with custom [colorStops].
|
||||
*
|
||||
* Stops are defined relative to [height] (0f = top, 1f = bottom of the fade region).
|
||||
*
|
||||
* Note: the gradient is drawn over the entire content area, so the last color stop's color
|
||||
* extends below the fade region down to the bottom. Pass [Color.Transparent] as the last stop
|
||||
* (or a color matching the underlying content) to avoid covering the area outside the fade.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.topFade(height: Dp, vararg colorStops: Pair<Float, Color>): Modifier = composed {
|
||||
drawWithContent {
|
||||
drawContent()
|
||||
|
||||
if (this.size.height <= 0f) return@drawWithContent
|
||||
val fraction = (height.toPx() / this.size.height).coerceIn(0f, 1f)
|
||||
if (fraction <= 0f) return@drawWithContent
|
||||
|
||||
val (start, end) = this.size.getFadeOffsets(FadePosition.TOP)
|
||||
|
||||
drawRect(
|
||||
brush = Brush.linearGradient(
|
||||
colorStops = colorStops
|
||||
.map { (stop, color) -> stop.coerceIn(0f, 1f) * fraction to color }
|
||||
.toTypedArray(),
|
||||
start = start,
|
||||
end = end,
|
||||
),
|
||||
size = this.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class FadePosition {
|
||||
TOP, BOTTOM, LEFT, RIGHT
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
166
core/ui/src/main/java/com/tangem/core/ui/ds2/fade/TangemFade.kt
Normal file
166
core/ui/src/main/java/com/tangem/core/ui/ds2/fade/TangemFade.kt
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.tangem.core.ui.ds2.fade
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
||||
import com.tangem.core.ui.res.LocalHazeState
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
|
||||
private const val SOLID_RATIO = 40f / 96f
|
||||
private const val HARD_ALPHA = 0.95f
|
||||
private const val SOFT_ALPHA = 0.6f
|
||||
private val FADE_HEIGHT = 96.dp
|
||||
private val BLUR_RADIUS = 20.dp
|
||||
|
||||
/**
|
||||
* Design-system fade overlay used at the top or bottom edge of scrollable content.
|
||||
*
|
||||
* Renders a 96dp-tall gradient (and optional solid block in the [TangemFade.Variant.Hard]
|
||||
* variant) tinted with `colors3.bg.primary`, so it blends with the page background and masks
|
||||
* content as it scrolls under the edge. Place it inside a [Box] and align with
|
||||
* [androidx.compose.ui.Alignment.TopCenter] / [androidx.compose.ui.Alignment.BottomCenter]
|
||||
* depending on [position].
|
||||
*
|
||||
* @param position which edge the fade is anchored to — controls the gradient direction.
|
||||
* @param variant [TangemFade.Variant.Hard] adds an opaque solid block next to the edge for a
|
||||
* harder cut-off, [TangemFade.Variant.Soft] is gradient-only and gentler.
|
||||
* @param blur when `true`, the content under the fade is blurred via Haze (radius 20dp,
|
||||
* progressive intensity matching [position]).
|
||||
* @param backgroundColor base color of the fade gradient — defaults to `colors3.bg.primary` so
|
||||
* the fade blends with the standard page background.
|
||||
* @param modifier modifier applied to the fade's root.
|
||||
*/
|
||||
@Composable
|
||||
fun TangemFade(
|
||||
position: TangemFade.Position,
|
||||
modifier: Modifier = Modifier,
|
||||
variant: TangemFade.Variant = TangemFade.Variant.Soft,
|
||||
blur: Boolean = false,
|
||||
backgroundColor: Color = TangemTheme.colors3.bg.primary,
|
||||
) {
|
||||
val hazeState = LocalHazeState.current
|
||||
val brush = buildBrush(color = backgroundColor, position = position, variant = variant)
|
||||
|
||||
val blurModifier = if (blur) {
|
||||
Modifier.hazeEffectTangem(state = hazeState) {
|
||||
blurRadius = BLUR_RADIUS
|
||||
fallbackTint = HazeTint(Color.Transparent)
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = if (position == TangemFade.Position.Top) 1f else 0f,
|
||||
endIntensity = if (position == TangemFade.Position.Top) 0f else 1f,
|
||||
preferPerformance = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(FADE_HEIGHT)
|
||||
.then(blurModifier)
|
||||
.background(brush),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildBrush(color: Color, position: TangemFade.Position, variant: TangemFade.Variant): Brush {
|
||||
val opaque = color.copy(alpha = HARD_ALPHA)
|
||||
val soft = color.copy(alpha = SOFT_ALPHA)
|
||||
val transparent = Color.Transparent
|
||||
|
||||
return when (variant) {
|
||||
TangemFade.Variant.Hard -> when (position) {
|
||||
TangemFade.Position.Top -> Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0f to opaque,
|
||||
SOLID_RATIO to opaque,
|
||||
1f to transparent,
|
||||
),
|
||||
)
|
||||
TangemFade.Position.Bottom -> Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0f to transparent,
|
||||
1f - SOLID_RATIO to opaque,
|
||||
1f to opaque,
|
||||
),
|
||||
)
|
||||
}
|
||||
TangemFade.Variant.Soft -> when (position) {
|
||||
TangemFade.Position.Top -> Brush.verticalGradient(colors = listOf(soft, transparent))
|
||||
TangemFade.Position.Bottom -> Brush.verticalGradient(colors = listOf(transparent, soft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object TangemFade {
|
||||
enum class Position { Top, Bottom }
|
||||
enum class Variant { Hard, Soft }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun TangemFade_Preview() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.secondary)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
TangemFade.Variant.entries.forEach { variant ->
|
||||
TangemFade.Position.entries.forEach { position ->
|
||||
FadePreviewRow(variant = variant, position = position)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FadePreviewRow(variant: TangemFade.Variant, position: TangemFade.Position) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "${variant.name} / ${position.name}",
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.background(TangemTheme.colors3.bg.accent.blue),
|
||||
) {
|
||||
TangemFade(
|
||||
position = position,
|
||||
variant = variant,
|
||||
modifier = Modifier.align(
|
||||
when (position) {
|
||||
TangemFade.Position.Top -> Alignment.TopCenter
|
||||
TangemFade.Position.Bottom -> Alignment.BottomCenter
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
468
core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt
Normal file
468
core/ui/src/main/java/com/tangem/core/ui/ds2/row/TangemRow.kt
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
package com.tangem.core.ui.ds2.row
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ripple.RippleAlpha
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalRippleConfiguration
|
||||
import androidx.compose.material3.RippleConfiguration
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.extensions.conditionalCompose
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
||||
/** Controls how the title and value sides share horizontal space in a [TangemRow]. */
|
||||
@Immutable
|
||||
enum class TangemRowContentLead { Equal, Start, End }
|
||||
|
||||
/** Vertical alignment between the start slot, content labels, and end slot in a [TangemRow]. */
|
||||
@Immutable
|
||||
enum class TangemRowVerticalAlignment { Top, Center }
|
||||
|
||||
/**
|
||||
* Design-system v2 row: a list-item container with start / end slots, title+subtitle on the
|
||||
* leading side, value+subvalue on the trailing side, and an optional slot below the row.
|
||||
*
|
||||
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=2344-1406)
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* TangemRow(
|
||||
* titleSlot = { TangemRowText("Network fee", TangemRowTextRole.Title) },
|
||||
* valueSlot = { TangemRowText("0.0001 ETH", TangemRowTextRole.Value) },
|
||||
* contentLead = TangemRowContentLead.Start,
|
||||
* onClick = { /* ... */ },
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* @param modifier Modifier applied to the row container.
|
||||
* @param divider Draws an inset bottom divider.
|
||||
* @param includeInnerPaddings Applies the default row padding. Disable when wrapped in a container
|
||||
* that already handles padding.
|
||||
* @param contentLead Strategy for sharing space between the title and value sides.
|
||||
* See [TangemRowContentLead].
|
||||
* @param verticalAlignment Vertical alignment of the slots. See [TangemRowVerticalAlignment].
|
||||
* @param titleSlot Leading primary label.
|
||||
* @param subtitleSlot Leading secondary label rendered under [titleSlot].
|
||||
* @param valueSlot Trailing primary label.
|
||||
* @param subvalueSlot Trailing secondary label rendered under [valueSlot].
|
||||
* @param startSlot Leading icon / control slot.
|
||||
* @param endSlot Trailing icon / control slot.
|
||||
* @param extraBottomSlot Full-width content rendered below the row.
|
||||
* @param interactionSource Interaction source used when [onClick] is non-null.
|
||||
* @param onClick Click handler. `null` makes the row non-interactive.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
fun TangemRow(
|
||||
modifier: Modifier = Modifier,
|
||||
divider: Boolean = false,
|
||||
includeInnerPaddings: Boolean = true,
|
||||
contentLead: TangemRowContentLead = TangemRowContentLead.Equal,
|
||||
verticalAlignment: TangemRowVerticalAlignment = TangemRowVerticalAlignment.Top,
|
||||
titleSlot: (@Composable RowScope.() -> Unit)? = null,
|
||||
subtitleSlot: (@Composable RowScope.() -> Unit)? = null,
|
||||
valueSlot: (@Composable RowScope.() -> Unit)? = null,
|
||||
subvalueSlot: (@Composable RowScope.() -> Unit)? = null,
|
||||
startSlot: (@Composable BoxScope.() -> Unit)? = null,
|
||||
endSlot: (@Composable BoxScope.() -> Unit)? = null,
|
||||
extraBottomSlot: (@Composable () -> Unit)? = null,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val resolvedInteractionSource: MutableInteractionSource? = if (onClick != null) {
|
||||
interactionSource ?: remember { MutableInteractionSource() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val isFocused = if (resolvedInteractionSource != null) {
|
||||
val isFocusedByState by resolvedInteractionSource.collectIsFocusedAsState()
|
||||
isFocusedByState
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
WithRowRipple(enabled = onClick != null) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.conditionalCompose(isFocused) { focusBorder() }
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(
|
||||
interactionSource = resolvedInteractionSource,
|
||||
indication = LocalIndication.current,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.conditionalCompose(divider) {
|
||||
bottomDivider(
|
||||
color = TangemTheme.colors3.border.secondary,
|
||||
width = TangemTheme.dimens3.borderWidth.sm,
|
||||
horizontalInset = TangemTheme.dimens3.spacing.s200,
|
||||
)
|
||||
}
|
||||
.conditionalCompose(includeInnerPaddings) {
|
||||
padding(TangemTheme.dimens3.spacing.s200)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = verticalAlignment.toCompose(),
|
||||
) {
|
||||
SideSlot(slot = startSlot, position = SideSlotPosition.Start)
|
||||
ContentLabels(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentLead = contentLead,
|
||||
verticalAlignment = verticalAlignment,
|
||||
titleSlot = titleSlot,
|
||||
subtitleSlot = subtitleSlot,
|
||||
valueSlot = valueSlot,
|
||||
subvalueSlot = subvalueSlot,
|
||||
)
|
||||
SideSlot(slot = endSlot, position = SideSlotPosition.End)
|
||||
}
|
||||
if (extraBottomSlot != null) {
|
||||
SpacerH(TangemTheme.dimens3.spacing.s100)
|
||||
extraBottomSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WithRowRipple(enabled: Boolean, content: @Composable () -> Unit) {
|
||||
if (enabled) {
|
||||
CompositionLocalProvider(LocalRippleConfiguration provides tangemRowRipple(), content = content)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun tangemRowRipple(): RippleConfiguration = RippleConfiguration(
|
||||
color = TangemTheme.colors3.interaction.press.default,
|
||||
rippleAlpha = RippleAlpha(
|
||||
draggedAlpha = 0f,
|
||||
focusedAlpha = 0f,
|
||||
hoveredAlpha = 0.05f,
|
||||
pressedAlpha = 0.1f,
|
||||
),
|
||||
)
|
||||
|
||||
private fun TangemRowVerticalAlignment.toCompose(): Alignment.Vertical = when (this) {
|
||||
TangemRowVerticalAlignment.Top -> Alignment.Top
|
||||
TangemRowVerticalAlignment.Center -> Alignment.CenterVertically
|
||||
}
|
||||
|
||||
private enum class SideSlotPosition { Start, End }
|
||||
|
||||
@Composable
|
||||
private fun SideSlot(slot: (@Composable BoxScope.() -> Unit)?, position: SideSlotPosition) {
|
||||
if (slot == null) return
|
||||
val spacing = TangemTheme.dimens3.spacing.s150
|
||||
val padding = when (position) {
|
||||
SideSlotPosition.Start -> Modifier.padding(end = spacing)
|
||||
SideSlotPosition.End -> Modifier.padding(start = spacing)
|
||||
}
|
||||
Box(modifier = padding, content = slot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays out the title/value columns side-by-side with [contentLead]-aware width sharing.
|
||||
*/
|
||||
@Suppress("UnnecessaryParentheses", "LongParameterList")
|
||||
@Composable
|
||||
private fun ContentLabels(
|
||||
contentLead: TangemRowContentLead,
|
||||
verticalAlignment: TangemRowVerticalAlignment,
|
||||
titleSlot: (@Composable RowScope.() -> Unit)?,
|
||||
subtitleSlot: (@Composable RowScope.() -> Unit)?,
|
||||
valueSlot: (@Composable RowScope.() -> Unit)?,
|
||||
subvalueSlot: (@Composable RowScope.() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasLeft = titleSlot != null || subtitleSlot != null
|
||||
val hasRight = valueSlot != null || subvalueSlot != null
|
||||
if (!hasLeft && !hasRight) return
|
||||
|
||||
Layout(
|
||||
modifier = modifier,
|
||||
content = {
|
||||
// Always emit two roots so `measurables` indices are stable in the measure block.
|
||||
LabelColumnContent(primary = titleSlot, secondary = subtitleSlot, alignment = Alignment.Start)
|
||||
LabelColumnContent(primary = valueSlot, secondary = subvalueSlot, alignment = Alignment.End)
|
||||
},
|
||||
) { measurables, constraints ->
|
||||
val titleMeasurable = measurables[0]
|
||||
val valueMeasurable = measurables[1]
|
||||
// Fall back to summed intrinsics when parent provides unbounded width — `layout()` cannot
|
||||
// report an infinite size.
|
||||
val rowWidth = if (constraints.hasBoundedWidth) {
|
||||
constraints.maxWidth
|
||||
} else {
|
||||
titleMeasurable.maxIntrinsicWidth(Int.MAX_VALUE) + valueMeasurable.maxIntrinsicWidth(Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
val (titleSlotWidth, valueSlotWidth) = when {
|
||||
!hasLeft -> 0 to rowWidth
|
||||
!hasRight -> rowWidth to 0
|
||||
else -> when (contentLead) {
|
||||
TangemRowContentLead.Equal -> (rowWidth / 2) to (rowWidth - rowWidth / 2)
|
||||
TangemRowContentLead.Start -> {
|
||||
// Value hugs (intrinsic), title fills the rest. Cap the hugging side at half
|
||||
// the row so the filling label never collapses to zero on overflow — degrades
|
||||
// to an equal split when the hugging side wants more than half.
|
||||
val v = valueMeasurable.maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(rowWidth / 2)
|
||||
(rowWidth - v) to v
|
||||
}
|
||||
TangemRowContentLead.End -> {
|
||||
// Title hugs (intrinsic), value fills the rest. Same half-row cap as above.
|
||||
val t = titleMeasurable.maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(rowWidth / 2)
|
||||
t to (rowWidth - t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val titlePlaceable = titleMeasurable.measure(
|
||||
constraints.copy(minWidth = 0, maxWidth = titleSlotWidth),
|
||||
)
|
||||
val valuePlaceable = valueMeasurable.measure(
|
||||
constraints.copy(minWidth = 0, maxWidth = valueSlotWidth),
|
||||
)
|
||||
|
||||
val rowHeight = maxOf(titlePlaceable.height, valuePlaceable.height)
|
||||
val yFor: (Int) -> Int = when (verticalAlignment) {
|
||||
TangemRowVerticalAlignment.Top -> { _ -> 0 }
|
||||
TangemRowVerticalAlignment.Center -> { h -> (rowHeight - h) / 2 }
|
||||
}
|
||||
|
||||
layout(rowWidth, rowHeight) {
|
||||
titlePlaceable.placeRelative(x = 0, y = yFor(titlePlaceable.height))
|
||||
// Filling side is pinned to the row's trailing edge.
|
||||
valuePlaceable.placeRelative(
|
||||
x = rowWidth - valuePlaceable.width,
|
||||
y = yFor(valuePlaceable.height),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelColumnContent(
|
||||
primary: (@Composable RowScope.() -> Unit)?,
|
||||
secondary: (@Composable RowScope.() -> Unit)?,
|
||||
alignment: Alignment.Horizontal,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens3.spacing.s025),
|
||||
horizontalAlignment = alignment,
|
||||
) {
|
||||
if (primary != null) LabelRow(alignment = alignment, content = primary)
|
||||
if (secondary != null) LabelRow(alignment = alignment, content = secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelRow(alignment: Alignment.Horizontal, content: @Composable RowScope.() -> Unit) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = TangemTheme.dimens3.spacing.s050,
|
||||
alignment = alignment,
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** Semantic role of a label inside a [TangemRow], driving its typography, color and alignment. */
|
||||
@Immutable
|
||||
enum class TangemRowTextRole { Title, Subtitle, Value, Subvalue }
|
||||
|
||||
/**
|
||||
* Default text styling for [TangemRow] label slots.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* TangemRowText(text = "Network fee", role = TangemRowTextRole.Title)
|
||||
* ```
|
||||
*
|
||||
* @param text Label text.
|
||||
* @param role Semantic role. See [TangemRowTextRole].
|
||||
* @param modifier Modifier applied to the underlying [Text].
|
||||
* @param maxLines Maximum number of visible lines before truncation.
|
||||
* @param overflow Overflow behavior. Defaults to ellipsis.
|
||||
*/
|
||||
@Composable
|
||||
fun TangemRowText(
|
||||
text: String,
|
||||
role: TangemRowTextRole,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 1,
|
||||
overflow: TextOverflow = TextOverflow.Ellipsis,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
color = rowTextColor(role),
|
||||
style = rowTextStyle(role),
|
||||
textAlign = rowTextAlign(role),
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rowTextStyle(role: TangemRowTextRole): TextStyle = when (role) {
|
||||
TangemRowTextRole.Title, TangemRowTextRole.Value -> TangemTheme.typography3.body.medium
|
||||
TangemRowTextRole.Subtitle, TangemRowTextRole.Subvalue -> TangemTheme.typography3.caption.medium
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rowTextColor(role: TangemRowTextRole): Color = when (role) {
|
||||
TangemRowTextRole.Title, TangemRowTextRole.Value -> TangemTheme.colors3.text.primary
|
||||
TangemRowTextRole.Subtitle, TangemRowTextRole.Subvalue -> TangemTheme.colors3.text.secondary
|
||||
}
|
||||
|
||||
private fun rowTextAlign(role: TangemRowTextRole): TextAlign = when (role) {
|
||||
TangemRowTextRole.Title, TangemRowTextRole.Subtitle -> TextAlign.Start
|
||||
TangemRowTextRole.Value, TangemRowTextRole.Subvalue -> TextAlign.End
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.focusBorder(): Modifier {
|
||||
val radius = TangemTheme.dimens3.borderRadius.b200
|
||||
val shape = remember(radius) { RoundedCornerShape(radius) }
|
||||
return border(
|
||||
width = TangemTheme.dimens3.borderWidth.md,
|
||||
color = TangemTheme.colors3.interaction.focusRing.default,
|
||||
shape = shape,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Modifier.bottomDivider(color: Color, width: Dp, horizontalInset: Dp): Modifier = drawWithContent {
|
||||
drawContent()
|
||||
val stroke = width.toPx()
|
||||
val inset = horizontalInset.toPx()
|
||||
val y = size.height - stroke / 2f
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(x = inset, y = y),
|
||||
end = Offset(x = size.width - inset, y = y),
|
||||
strokeWidth = stroke,
|
||||
)
|
||||
}
|
||||
|
||||
// region Previews
|
||||
|
||||
@Preview(name = "Light", showBackground = true)
|
||||
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
|
||||
@Composable
|
||||
private fun TangemRowPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(vertical = 16.dp),
|
||||
) {
|
||||
// Equal lead, text only, with divider
|
||||
TangemRow(
|
||||
divider = true,
|
||||
contentLead = TangemRowContentLead.Equal,
|
||||
titleSlot = { TangemRowText(text = "Title", role = TangemRowTextRole.Title) },
|
||||
subtitleSlot = { TangemRowText(text = "Subtitle", role = TangemRowTextRole.Subtitle) },
|
||||
valueSlot = { TangemRowText(text = "Value", role = TangemRowTextRole.Value) },
|
||||
subvalueSlot = { TangemRowText(text = "Subvalue", role = TangemRowTextRole.Subvalue) },
|
||||
)
|
||||
// Start lead — title hugs, value side fills
|
||||
TangemRow(
|
||||
divider = true,
|
||||
contentLead = TangemRowContentLead.Start,
|
||||
titleSlot = { TangemRowText(text = "Network fee", role = TangemRowTextRole.Title) },
|
||||
valueSlot = { TangemRowText(text = "0.0001 ETH", role = TangemRowTextRole.Value) },
|
||||
subvalueSlot = { TangemRowText(text = "≈ $0.32", role = TangemRowTextRole.Subvalue) },
|
||||
)
|
||||
// End lead — value hugs, title side fills
|
||||
TangemRow(
|
||||
divider = true,
|
||||
contentLead = TangemRowContentLead.End,
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = "Recipient with a very long address that should ellipsize",
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
},
|
||||
valueSlot = { TangemRowText(text = "0x1234…abcd", role = TangemRowTextRole.Value) },
|
||||
)
|
||||
// Side slots + clickable + centered alignment
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
startSlot = {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
painter = painterResource(id = R.drawable.ic_chevron_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.primary,
|
||||
)
|
||||
},
|
||||
titleSlot = { TangemRowText(text = "Settings", role = TangemRowTextRole.Title) },
|
||||
subtitleSlot = {
|
||||
TangemRowText(text = "Tap to configure", role = TangemRowTextRole.Subtitle)
|
||||
},
|
||||
endSlot = {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
painter = painterResource(id = R.drawable.ic_chevron_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.secondary,
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
)
|
||||
// Title only — exercises nullable slots / single LabelRow path
|
||||
TangemRow(
|
||||
titleSlot = { TangemRowText(text = "Single-line row", role = TangemRowTextRole.Title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.feature.tester.presentation.storybook.page.ds.fade
|
||||
|
||||
import com.tangem.core.ui.ds2.fade.TangemFade
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory
|
||||
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
|
||||
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
|
||||
|
||||
internal fun StateUpdater<TangemFadeStory>.build(): TangemFadeStory {
|
||||
return TangemFadeStory(
|
||||
variant = TangemFade.Variant.Hard,
|
||||
position = TangemFade.Position.Top,
|
||||
isBlur = false,
|
||||
onVariantChange = { variant ->
|
||||
updateStory { it.copy(variant = variant) }
|
||||
},
|
||||
onPositionChange = { position ->
|
||||
updateStory { it.copy(position = position) }
|
||||
},
|
||||
onBlurToggle = {
|
||||
updateStory { it.copy(isBlur = !it.isBlur) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
internal val tangemFadeStoryFactory
|
||||
get() = storyPageFactory(StateUpdater<TangemFadeStory>::build)
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.tangem.feature.tester.presentation.storybook.page.ds.fade
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.haze.hazeSourceTangem
|
||||
import com.tangem.core.ui.ds2.fade.TangemFade
|
||||
import com.tangem.core.ui.res.LocalHazeState
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory
|
||||
|
||||
@Composable
|
||||
internal fun TangemFadeStory(state: TangemFadeStory, modifier: Modifier = Modifier) {
|
||||
val hazeState = LocalHazeState.current
|
||||
DisposableEffect(state.isBlur) {
|
||||
val wasBlurEnabled = hazeState.blurEnabled
|
||||
hazeState.blurEnabled = state.isBlur
|
||||
onDispose { hazeState.blurEnabled = wasBlurEnabled }
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ComponentPreview(state = state)
|
||||
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
|
||||
PositionSelector(selected = state.position, onSelect = state.onPositionChange)
|
||||
Toggles(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComponentPreview(state: TangemFadeStory) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
) {
|
||||
Backdrop(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSourceTangem(zIndex = 0f),
|
||||
)
|
||||
TangemFade(
|
||||
position = state.position,
|
||||
variant = state.variant,
|
||||
blur = state.isBlur,
|
||||
modifier = Modifier.align(
|
||||
when (state.position) {
|
||||
TangemFade.Position.Top -> Alignment.TopCenter
|
||||
TangemFade.Position.Bottom -> Alignment.BottomCenter
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Backdrop(modifier: Modifier = Modifier) {
|
||||
val bands = remember {
|
||||
listOf(
|
||||
Color(0xFFFF1744),
|
||||
Color(0xFFFF9100),
|
||||
Color(0xFFFFEA00),
|
||||
Color(0xFF00E676),
|
||||
Color(0xFF00B8D4),
|
||||
Color(0xFF2962FF),
|
||||
Color(0xFFD500F9),
|
||||
)
|
||||
}
|
||||
val stops = remember(bands) {
|
||||
buildList {
|
||||
bands.forEachIndexed { index, color ->
|
||||
val start = index.toFloat() / bands.size
|
||||
val end = (index + 1).toFloat() / bands.size
|
||||
add(start to color)
|
||||
add(end to color)
|
||||
}
|
||||
}.toTypedArray()
|
||||
}
|
||||
val tilePx = with(LocalDensity.current) { 160.dp.toPx() }
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
brush = Brush.linearGradient(
|
||||
colorStops = stops,
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(tilePx, tilePx),
|
||||
tileMode = TileMode.Repeated,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariantSelector(selected: TangemFade.Variant, onSelect: (TangemFade.Variant) -> Unit) {
|
||||
Section(label = "Variant") {
|
||||
ChipGrid(
|
||||
items = TangemFade.Variant.entries,
|
||||
label = { it.name },
|
||||
isSelected = { it == selected },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PositionSelector(selected: TangemFade.Position, onSelect: (TangemFade.Position) -> Unit) {
|
||||
Section(label = "Position") {
|
||||
ChipGrid(
|
||||
items = TangemFade.Position.entries,
|
||||
label = { it.name },
|
||||
isSelected = { it == selected },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toggles(state: TangemFadeStory) {
|
||||
Section(label = "Flags") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ToggleRow(label = "blur", checked = state.isBlur, onToggle = state.onBlurToggle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(label: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = label,
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
|
||||
val shape = RoundedCornerShape(50)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(shape)
|
||||
.background(TangemTheme.colors2.surface.level2)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = TangemTheme.colors2.border.neutral.secondary,
|
||||
shape = shape,
|
||||
)
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
Chip(
|
||||
label = label(item),
|
||||
selected = isSelected(item),
|
||||
onClick = { onSelect(item) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val chipShape = RoundedCornerShape(50)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.clip(chipShape)
|
||||
.background(
|
||||
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(TangemTheme.colors2.surface.level2)
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TangemTheme.typography.body1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
Text(
|
||||
text = if (checked) "ON" else "OFF",
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.tangem.feature.tester.presentation.storybook.page.ds.row
|
||||
|
||||
import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory
|
||||
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
|
||||
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
|
||||
|
||||
internal fun StateUpdater<TangemRowStory>.build(): TangemRowStory {
|
||||
return TangemRowStory(
|
||||
contentLead = TangemRowContentLead.Equal,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Top,
|
||||
background = TangemRowStory.Background.BgPrimary,
|
||||
divider = false,
|
||||
includeInnerPaddings = true,
|
||||
isClickable = false,
|
||||
hasStartSlot = false,
|
||||
hasEndSlot = false,
|
||||
hasSubtitle = true,
|
||||
hasValue = true,
|
||||
hasSubvalue = true,
|
||||
hasExtraBottom = false,
|
||||
longTitle = false,
|
||||
textScale = 1f,
|
||||
onContentLeadChange = { contentLead ->
|
||||
updateStory { it.copy(contentLead = contentLead) }
|
||||
},
|
||||
onVerticalAlignmentChange = { alignment ->
|
||||
updateStory { it.copy(verticalAlignment = alignment) }
|
||||
},
|
||||
onBackgroundChange = { background ->
|
||||
updateStory { it.copy(background = background) }
|
||||
},
|
||||
onDividerToggle = {
|
||||
updateStory { it.copy(divider = !it.divider) }
|
||||
},
|
||||
onInnerPaddingsToggle = {
|
||||
updateStory { it.copy(includeInnerPaddings = !it.includeInnerPaddings) }
|
||||
},
|
||||
onClickableToggle = {
|
||||
updateStory { it.copy(isClickable = !it.isClickable) }
|
||||
},
|
||||
onStartSlotToggle = {
|
||||
updateStory { it.copy(hasStartSlot = !it.hasStartSlot) }
|
||||
},
|
||||
onEndSlotToggle = {
|
||||
updateStory { it.copy(hasEndSlot = !it.hasEndSlot) }
|
||||
},
|
||||
onSubtitleToggle = {
|
||||
updateStory { it.copy(hasSubtitle = !it.hasSubtitle) }
|
||||
},
|
||||
onValueToggle = {
|
||||
updateStory { it.copy(hasValue = !it.hasValue) }
|
||||
},
|
||||
onSubvalueToggle = {
|
||||
updateStory { it.copy(hasSubvalue = !it.hasSubvalue) }
|
||||
},
|
||||
onExtraBottomToggle = {
|
||||
updateStory { it.copy(hasExtraBottom = !it.hasExtraBottom) }
|
||||
},
|
||||
onLongTitleToggle = {
|
||||
updateStory { it.copy(longTitle = !it.longTitle) }
|
||||
},
|
||||
onTextScaleChange = { scale ->
|
||||
updateStory { it.copy(textScale = scale) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
internal val tangemRowStoryFactory
|
||||
get() = storyPageFactory(StateUpdater<TangemRowStory>::build)
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.tangem.feature.tester.presentation.storybook.page.ds.row
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
||||
import com.tangem.core.ui.ds2.row.TangemRowText
|
||||
import com.tangem.core.ui.ds2.row.TangemRowTextRole
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory
|
||||
import com.tangem.feature.tester.presentation.storybook.entity.TangemRowStory.Background
|
||||
|
||||
private const val SHORT_TITLE = "Title"
|
||||
private const val LONG_TITLE = "Recipient address that should ellipsize when constrained by width"
|
||||
private const val SUBTITLE = "Subtitle"
|
||||
private const val VALUE = "0.0421 ETH"
|
||||
private const val SUBVALUE = "≈ $124.80"
|
||||
|
||||
@Composable
|
||||
internal fun TangemRowStory(state: TangemRowStory, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(TangemTheme.colors.background.primary)
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Preview stays pinned at the top.
|
||||
ComponentPreview(state = state)
|
||||
// Only the controls scroll.
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ContentLeadSelector(selected = state.contentLead, onSelect = state.onContentLeadChange)
|
||||
VerticalAlignmentSelector(
|
||||
selected = state.verticalAlignment,
|
||||
onSelect = state.onVerticalAlignmentChange,
|
||||
)
|
||||
BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange)
|
||||
TextScaleSlider(value = state.textScale, onChange = state.onTextScaleChange)
|
||||
Toggles(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComponentPreview(state: TangemRowStory) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
) {
|
||||
PreviewBackground(background = state.background, modifier = Modifier.matchParentSize())
|
||||
val baseDensity = LocalDensity.current
|
||||
val scaledDensity = remember(baseDensity, state.textScale) {
|
||||
Density(density = baseDensity.density, fontScale = state.textScale)
|
||||
}
|
||||
CompositionLocalProvider(LocalDensity provides scaledDensity) {
|
||||
Box(modifier = Modifier.padding(vertical = 24.dp)) {
|
||||
StorybookRow(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StorybookRow(state: TangemRowStory) {
|
||||
val titleText = if (state.longTitle) LONG_TITLE else SHORT_TITLE
|
||||
TangemRow(
|
||||
contentLead = state.contentLead,
|
||||
verticalAlignment = state.verticalAlignment,
|
||||
divider = state.divider,
|
||||
includeInnerPaddings = state.includeInnerPaddings,
|
||||
titleSlot = { TangemRowText(text = titleText, role = TangemRowTextRole.Title) },
|
||||
subtitleSlot = if (state.hasSubtitle) {
|
||||
{ TangemRowText(text = SUBTITLE, role = TangemRowTextRole.Subtitle) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
valueSlot = if (state.hasValue) {
|
||||
{ TangemRowText(text = VALUE, role = TangemRowTextRole.Value) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
subvalueSlot = if (state.hasSubvalue) {
|
||||
{ TangemRowText(text = SUBVALUE, role = TangemRowTextRole.Subvalue) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
startSlot = if (state.hasStartSlot) {
|
||||
{
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
painter = painterResource(id = R.drawable.ic_information_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
endSlot = if (state.hasEndSlot) {
|
||||
{
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
painter = painterResource(id = R.drawable.ic_chevron_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.secondary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
extraBottomSlot = if (state.hasExtraBottom) {
|
||||
{
|
||||
Text(
|
||||
text = "Extra bottom slot — multi-line description content.",
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = if (state.isClickable) {
|
||||
{ /* no-op — ripple + focus demo */ }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) {
|
||||
when (background) {
|
||||
Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary))
|
||||
Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary))
|
||||
Background.BgBrand -> Box(modifier.background(TangemTheme.colors3.bg.brand))
|
||||
Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentLeadSelector(selected: TangemRowContentLead, onSelect: (TangemRowContentLead) -> Unit) {
|
||||
Section(label = "Content lead") {
|
||||
ChipGrid(
|
||||
items = TangemRowContentLead.entries,
|
||||
label = { it.name },
|
||||
isSelected = { it == selected },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalAlignmentSelector(
|
||||
selected: TangemRowVerticalAlignment,
|
||||
onSelect: (TangemRowVerticalAlignment) -> Unit,
|
||||
) {
|
||||
Section(label = "Vertical alignment") {
|
||||
ChipGrid(
|
||||
items = TangemRowVerticalAlignment.entries,
|
||||
label = { it.name },
|
||||
isSelected = { it == selected },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) {
|
||||
Section(label = "Background") {
|
||||
ChipGrid(
|
||||
items = Background.entries,
|
||||
label = { it.label },
|
||||
isSelected = { it == selected },
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextScaleSlider(value: Float, onChange: (Float) -> Unit) {
|
||||
Section(label = "Text scale: ${"%.2f".format(value)}x") {
|
||||
Slider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
valueRange = 0.5f..2f,
|
||||
steps = 14,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = TangemTheme.colors.text.accent,
|
||||
activeTrackColor = TangemTheme.colors.text.accent,
|
||||
activeTickColor = TangemTheme.colors2.surface.level3,
|
||||
inactiveTrackColor = TangemTheme.colors2.surface.level3,
|
||||
inactiveTickColor = TangemTheme.colors.text.accent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toggles(state: TangemRowStory) {
|
||||
Section(label = "Flags") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ToggleRow(label = "divider", checked = state.divider, onToggle = state.onDividerToggle)
|
||||
ToggleRow(
|
||||
label = "includeInnerPaddings",
|
||||
checked = state.includeInnerPaddings,
|
||||
onToggle = state.onInnerPaddingsToggle,
|
||||
)
|
||||
ToggleRow(
|
||||
label = "clickable (ripple + focus)",
|
||||
checked = state.isClickable,
|
||||
onToggle = state.onClickableToggle,
|
||||
)
|
||||
ToggleRow(
|
||||
label = "startSlot (leading icon)",
|
||||
checked = state.hasStartSlot,
|
||||
onToggle = state.onStartSlotToggle,
|
||||
)
|
||||
ToggleRow(
|
||||
label = "endSlot (trailing icon)",
|
||||
checked = state.hasEndSlot,
|
||||
onToggle = state.onEndSlotToggle,
|
||||
)
|
||||
ToggleRow(label = "subtitle", checked = state.hasSubtitle, onToggle = state.onSubtitleToggle)
|
||||
ToggleRow(label = "value", checked = state.hasValue, onToggle = state.onValueToggle)
|
||||
ToggleRow(label = "subvalue", checked = state.hasSubvalue, onToggle = state.onSubvalueToggle)
|
||||
ToggleRow(
|
||||
label = "extraBottomSlot",
|
||||
checked = state.hasExtraBottom,
|
||||
onToggle = state.onExtraBottomToggle,
|
||||
)
|
||||
ToggleRow(
|
||||
label = "long title (ellipsis test)",
|
||||
checked = state.longTitle,
|
||||
onToggle = state.onLongTitleToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(label: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = label,
|
||||
style = TangemTheme.typography.subtitle1,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
|
||||
val shape = RoundedCornerShape(50)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(shape)
|
||||
.background(TangemTheme.colors2.surface.level2)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = TangemTheme.colors2.border.neutral.secondary,
|
||||
shape = shape,
|
||||
)
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
Chip(
|
||||
label = label(item),
|
||||
selected = isSelected(item),
|
||||
onClick = { onSelect(item) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val chipShape = RoundedCornerShape(50)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.clip(chipShape)
|
||||
.background(
|
||||
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(TangemTheme.colors2.surface.level2)
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
Text(
|
||||
text = if (checked) "ON" else "OFF",
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<String, String> {
|
||||
val parts = template.split(IMAGE_PLACEHOLDER, limit = 2)
|
||||
return if (parts.size == 2) parts[0] to parts[1] else template to ""
|
||||
}
|
||||
/**
|
||||
* Returns the title template with its `%1$s` / `%2$s` placeholders replaced by [TOKEN_MARKER] /
|
||||
* [SECONDARY_MARKER] sentinels (and `%%image%%` collapsed to [IMAGE_PLACEHOLDER]).
|
||||
*
|
||||
* Feeding the markers in as format args lets [String.format] resolve all escaping for us, so
|
||||
* [parseTemplate] sees a clean string and the real names stay un-substituted until render time.
|
||||
*/
|
||||
@Composable
|
||||
private fun formattedTitleTemplate(@StringRes id: Int): String = stringResourceSafe(id, TOKEN_MARKER, SECONDARY_MARKER)
|
||||
|
||||
@Composable
|
||||
private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit): Map<String, InlineTextContent> {
|
||||
private fun rememberIconInlineContent(
|
||||
iconSize: Dp,
|
||||
icon: @Composable () -> Unit,
|
||||
): ImmutableMap<String, InlineTextContent> {
|
||||
val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() }
|
||||
val currentIcon by rememberUpdatedState(icon)
|
||||
return remember(iconSizeSp) {
|
||||
mapOf(
|
||||
persistentMapOf(
|
||||
ICON_INLINE_ID to InlineTextContent(
|
||||
placeholder = Placeholder(
|
||||
width = iconSizeSp,
|
||||
|
|
@ -261,23 +271,29 @@ private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit
|
|||
|
||||
@Composable
|
||||
private fun rememberShouldShowFullText(
|
||||
beforeIcon: String,
|
||||
afterIcon: String,
|
||||
secondaryName: String,
|
||||
segments: ImmutableList<TitleSegment>,
|
||||
tokenName: String,
|
||||
appearance: TitleAppearance,
|
||||
maxWidthPx: Int,
|
||||
): Boolean {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
return remember(beforeIcon, afterIcon, secondaryName, maxWidthPx, appearance, density) {
|
||||
return remember(segments, tokenName, maxWidthPx, appearance, density) {
|
||||
if (maxWidthPx <= 0) return@remember true
|
||||
val fullTextWidthPx = measurer
|
||||
.measure(text = beforeIcon + afterIcon, style = appearance.style, softWrap = false)
|
||||
.size.width
|
||||
val secondaryWidthPx = measurer
|
||||
.measure(text = secondaryName, style = appearance.style, softWrap = false)
|
||||
.size.width
|
||||
val staticWidthPx = (fullTextWidthPx - secondaryWidthPx).coerceAtLeast(0)
|
||||
val staticText = buildString {
|
||||
for (segment in segments) {
|
||||
when (segment) {
|
||||
TitleSegment.Token -> append(tokenName)
|
||||
is TitleSegment.Plain -> append(segment.text)
|
||||
TitleSegment.Secondary, TitleSegment.Image -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
val staticWidthPx = if (staticText.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
measurer.measure(text = staticText, style = appearance.style, softWrap = false).size.width
|
||||
}
|
||||
val iconReservePx = with(density) {
|
||||
(appearance.iconSize + appearance.spacing * 2).toPx()
|
||||
}.toInt()
|
||||
|
|
@ -288,16 +304,25 @@ private fun rememberShouldShowFullText(
|
|||
|
||||
@Composable
|
||||
private fun FullPhraseTitle(
|
||||
beforeIcon: String,
|
||||
afterIcon: String,
|
||||
segments: ImmutableList<TitleSegment>,
|
||||
tokenName: String,
|
||||
secondaryName: String,
|
||||
style: TextStyle,
|
||||
inlineContent: Map<String, InlineTextContent>,
|
||||
inlineContent: ImmutableMap<String, InlineTextContent>,
|
||||
) {
|
||||
val fullText = remember(beforeIcon, afterIcon) {
|
||||
val tertiaryColor = TangemTheme.colors2.text.neutral.tertiary
|
||||
val fullText = remember(segments, tokenName, secondaryName, tertiaryColor) {
|
||||
buildAnnotatedString {
|
||||
append(beforeIcon)
|
||||
appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER)
|
||||
append(afterIcon)
|
||||
for (segment in segments) {
|
||||
when (segment) {
|
||||
TitleSegment.Token -> append(tokenName)
|
||||
TitleSegment.Secondary -> append(secondaryName)
|
||||
TitleSegment.Image -> appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER)
|
||||
is TitleSegment.Plain -> withStyle(SpanStyle(color = tertiaryColor)) {
|
||||
append(segment.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
|
|
@ -311,6 +336,47 @@ private fun FullPhraseTitle(
|
|||
)
|
||||
}
|
||||
|
||||
private sealed interface TitleSegment {
|
||||
data object Token : TitleSegment
|
||||
data object Secondary : TitleSegment
|
||||
data object Image : TitleSegment
|
||||
data class Plain(val text: String) : TitleSegment
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the [String.format]ed template (see [formattedTitleTemplate]) into ordered segments.
|
||||
*
|
||||
* Markers appear in their resolved form: [TOKEN_MARKER] / [SECONDARY_MARKER] for the names and
|
||||
* [IMAGE_PLACEHOLDER] for the icon (escaping was already collapsed by [String.format]). Anything
|
||||
* else is plain connecting text painted as tertiary.
|
||||
*/
|
||||
private fun parseTemplate(template: String): ImmutableList<TitleSegment> {
|
||||
val segments = mutableListOf<TitleSegment>()
|
||||
val plain = StringBuilder()
|
||||
|
||||
fun flushPlain() {
|
||||
if (plain.isNotEmpty()) {
|
||||
segments += TitleSegment.Plain(plain.toString())
|
||||
plain.clear()
|
||||
}
|
||||
}
|
||||
|
||||
var i = 0
|
||||
while (i < template.length) {
|
||||
val marker = MARKERS.firstOrNull { (text, _) -> template.startsWith(text, i) }
|
||||
if (marker != null) {
|
||||
flushPlain()
|
||||
segments += marker.second
|
||||
i += marker.first.length
|
||||
} else {
|
||||
plain.append(template[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
flushPlain()
|
||||
return segments.toImmutableList()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FallbackTokenWithIconTitle(tokenName: String, appearance: TitleAppearance, icon: @Composable () -> Unit) {
|
||||
Row(
|
||||
|
|
@ -342,7 +408,17 @@ private data class TitleAppearance(
|
|||
|
||||
private const val ICON_INLINE_ID = "account_icon"
|
||||
private const val IMAGE_PLACEHOLDER = "%image%"
|
||||
private const val TOKEN_MARKER = "%1\$s"
|
||||
private const val SECONDARY_MARKER = "%2\$s"
|
||||
|
||||
// Order matters: longer/overlapping markers must be tried first by [parseTemplate].
|
||||
private val MARKERS: List<Pair<String, TitleSegment>> = listOf(
|
||||
IMAGE_PLACEHOLDER to TitleSegment.Image,
|
||||
TOKEN_MARKER to TitleSegment.Token,
|
||||
SECONDARY_MARKER to TitleSegment.Secondary,
|
||||
)
|
||||
|
||||
private val ACTION_BLUR_RADIUS = 8.dp
|
||||
private val MIN_TITLE_FONT_SIZE = 12.sp
|
||||
private val MAX_TITLE_FONT_SIZE = 16.sp
|
||||
private val MIN_SECONDARY_NAME_WIDTH = 48.dp
|
||||
|
|
@ -355,20 +431,22 @@ private fun TokenDetailsTopBar_Preview(
|
|||
@PreviewParameter(TokenDetailsTopBarPreviewProvider::class) titleState: TitleState,
|
||||
) {
|
||||
TangemThemePreviewRedesign {
|
||||
TokenDetailsTopBar(
|
||||
topAppBarUM = TokenDetailsTopAppBarUM(
|
||||
titleState = titleState,
|
||||
subtitle = stringReference("ERC-20 in Ethereum network"),
|
||||
onBackClick = {},
|
||||
menuItems = persistentListOf(
|
||||
TangemDropdownMenuItem(
|
||||
title = stringReference("Hide Token"),
|
||||
textColor = themedColor { TangemTheme.colors.text.warning },
|
||||
onClick = {},
|
||||
ProvideHaze {
|
||||
TokenDetailsTopBar(
|
||||
topAppBarUM = TokenDetailsTopAppBarUM(
|
||||
titleState = titleState,
|
||||
subtitle = stringReference("ERC-20 in Ethereum network"),
|
||||
onBackClick = {},
|
||||
menuItems = persistentListOf(
|
||||
TangemDropdownMenuItem(
|
||||
title = stringReference("Hide Token"),
|
||||
textColor = themedColor { TangemTheme.colors.text.warning },
|
||||
onClick = {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,15 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.TextShimmer
|
||||
|
|
@ -32,11 +36,7 @@ import com.tangem.core.ui.ds.placeholder.TextPlaceholder
|
|||
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
|
||||
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
|
||||
import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed
|
||||
import com.tangem.core.ui.extensions.orMaskWithStars
|
||||
import com.tangem.core.ui.extensions.resolveAnnotatedReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.test.MainScreenTestTags
|
||||
|
|
@ -58,10 +58,12 @@ internal fun WalletBalance(
|
|||
buttons: ImmutableList<TangemButtonUM>,
|
||||
isBalanceHidden: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onSubtitleBottomChange: (Dp) -> Unit = {},
|
||||
) {
|
||||
val collapsedFraction = behavior.state.collapsedFraction
|
||||
val alpha = 1f - collapsedFraction
|
||||
val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE)
|
||||
val density = LocalDensity.current
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
|
|
@ -85,7 +87,15 @@ internal fun WalletBalance(
|
|||
isBalanceHidden = isBalanceHidden,
|
||||
)
|
||||
SpacerH(TangemTheme.dimens2.x3)
|
||||
SubtitleRow(walletBalanceUM = walletBalanceUM)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val rawBottomPx = coordinates.boundsInRoot().bottom
|
||||
if (rawBottomPx <= 0f) return@onGloballyPositioned
|
||||
onSubtitleBottomChange(with(density) { rawBottomPx.toDp() })
|
||||
},
|
||||
) {
|
||||
SubtitleRow(walletBalanceUM = walletBalanceUM)
|
||||
}
|
||||
}
|
||||
SpacerH(TangemTheme.dimens2.x2)
|
||||
ActionButtons(buttons)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
package com.tangem.feature.wallet.presentation.wallet.state.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.ui.tokens.TokenConverterParams
|
||||
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.ds.image.DeviceIconUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.core.utils.lceError
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TokensGroupType
|
||||
import com.tangem.domain.models.TokensSortType
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.account.Account.CryptoPortfolio.Companion.createMainAccount
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.models.tokenlist.TokenList
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
|
||||
import com.tangem.feature.wallet.presentation.wallet.state.model.*
|
||||
import com.tangem.features.tangempay.entity.TangemPayMainUM
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
class SetTokenListTransformerTest {
|
||||
|
||||
private val userWalletId = UserWalletId("00")
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty account list WHEN transform THEN tokens are Empty and buttons disabled`() {
|
||||
val transformer = createTransformer(accountList = emptyAccountList())
|
||||
|
||||
val result = transformer.transform(walletUM(buttonEnabled = true)) as WalletUM.Content
|
||||
|
||||
assertThat(result.tokensListUM).isInstanceOf(WalletTokensListUM.Empty::class.java)
|
||||
assertThat(result.buttons).hasSize(1)
|
||||
assertThat(result.buttons.single().isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-empty account list WHEN transform THEN tokens not Empty and buttons enabled`() {
|
||||
val transformer = createTransformer(accountList = nonEmptyAccountList())
|
||||
|
||||
val result = transformer.transform(walletUM(buttonEnabled = false)) as WalletUM.Content
|
||||
|
||||
assertThat(result.tokensListUM).isNotInstanceOf(WalletTokensListUM.Empty::class.java)
|
||||
assertThat(result.buttons).hasSize(1)
|
||||
assertThat(result.buttons.single().isEnabled).isTrue()
|
||||
}
|
||||
|
||||
private fun createTransformer(accountList: AccountStatusList): SetTokenListTransformer {
|
||||
val userWallet = mockk<UserWallet.Hot>(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
return SetTokenListTransformer(
|
||||
params = TokenConverterParams.Account(
|
||||
accountList = accountList,
|
||||
expandedAccounts = emptySet(),
|
||||
),
|
||||
userWallet = userWallet,
|
||||
appCurrency = AppCurrency.Default,
|
||||
clickIntents = mockk<WalletClickIntents>(relaxed = true),
|
||||
shouldShowMainPromo = false,
|
||||
isAccountsModeEnabled = false,
|
||||
isRedesignEnabled = true,
|
||||
isAddAndManageTokensEnabled = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun emptyAccountList(): AccountStatusList = AccountStatusList(
|
||||
userWalletId = userWalletId,
|
||||
accountStatuses = listOf(mainCryptoPortfolioStatus(tokenList = TokenList.Empty)),
|
||||
totalAccounts = 1,
|
||||
totalArchivedAccounts = 0,
|
||||
totalFiatBalance = TotalFiatBalance.Loading,
|
||||
sortType = TokensSortType.NONE,
|
||||
groupType = TokensGroupType.NONE,
|
||||
)
|
||||
|
||||
private fun nonEmptyAccountList(): AccountStatusList {
|
||||
val token = createToken()
|
||||
val tokenList = TokenList.Ungrouped(
|
||||
currencies = listOf(createLoadedStatus(token)),
|
||||
totalFiatBalance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL),
|
||||
sortedBy = TokensSortType.NONE,
|
||||
)
|
||||
return AccountStatusList(
|
||||
userWalletId = userWalletId,
|
||||
accountStatuses = listOf(mainCryptoPortfolioStatus(tokenList = tokenList)),
|
||||
totalAccounts = 1,
|
||||
totalArchivedAccounts = 0,
|
||||
totalFiatBalance = TotalFiatBalance.Loading,
|
||||
sortType = TokensSortType.NONE,
|
||||
groupType = TokensGroupType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mainCryptoPortfolioStatus(tokenList: TokenList): AccountStatus.CryptoPortfolio =
|
||||
AccountStatus.CryptoPortfolio(
|
||||
account = createMainAccount(userWalletId),
|
||||
tokenList = tokenList,
|
||||
priceChangeLce = Unit.lceError(),
|
||||
)
|
||||
|
||||
private fun walletUM(buttonEnabled: Boolean): WalletUM.Content = WalletUM.Content(
|
||||
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
|
||||
walletsBalanceUM = WalletBalanceUM.Loading(
|
||||
id = userWalletId,
|
||||
name = "test",
|
||||
deviceIcon = DeviceIconUM.Mobile,
|
||||
),
|
||||
buttons = persistentListOf(
|
||||
TangemButtonUM(
|
||||
text = stringReference(value = "Buy"),
|
||||
type = TangemButtonType.Secondary,
|
||||
onClick = {},
|
||||
isEnabled = buttonEnabled,
|
||||
),
|
||||
),
|
||||
notifications = persistentListOf(),
|
||||
notificationsCarousel = persistentListOf(),
|
||||
tokensListUM = WalletTokensListUM.Loading,
|
||||
nftState = WalletNFTItemUM.Hidden,
|
||||
type = WalletType.Hot,
|
||||
tangemPayMainUM = TangemPayMainUM.Empty,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None),
|
||||
name = "ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.ERC20,
|
||||
hasFiatFeeRate = false,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = "0xABCDEF"),
|
||||
),
|
||||
network = network,
|
||||
name = "Token",
|
||||
symbol = "TKN",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xABCDEF",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createLoadedStatus(token: CryptoCurrency.Token): CryptoCurrencyStatus {
|
||||
val networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "addr",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
)
|
||||
val value = CryptoCurrencyStatus.Loaded(
|
||||
amount = BigDecimal.ONE,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = networkAddress,
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
)
|
||||
return CryptoCurrencyStatus(currency = token, value = value)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,42 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.lib.auth"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.configToggles)
|
||||
|
||||
/** Tangem libraries */
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
/** Firebase */
|
||||
implementation(platform(deps.firebase.bom))
|
||||
implementation(deps.firebase.crashlytics)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.arrow.core)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.mockk)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.lib.auth
|
||||
|
||||
interface AuthFeatureToggles {
|
||||
val isBackendAuthenticationEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.lib.auth.devicekey
|
||||
|
||||
import arrow.core.Option
|
||||
|
||||
/**
|
||||
* Manages a device-bound secp256r1 keypair in Android Keystore (TEE/StrongBox).
|
||||
* The private key never leaves the secure hardware.
|
||||
*/
|
||||
interface DeviceKeyManager {
|
||||
|
||||
/**
|
||||
* Ensures the device keypair exists. Generates one if missing.
|
||||
* Never throws — generation failures are logged and reported via the return value.
|
||||
* @return `true` if a new keypair was generated, `false` if it already existed or generation failed
|
||||
*/
|
||||
suspend fun generateIfMissing(): Boolean
|
||||
|
||||
/** Raw uncompressed public key (0x04 || x || y), or [arrow.core.None] if it cannot be read. */
|
||||
suspend fun getPublicKey(): Option<ByteArray>
|
||||
|
||||
/**
|
||||
* Signs [data] with SHA256withECDSA using the device private key.
|
||||
* @return raw 64-byte signature (r || s), each component zero-padded to 32 bytes
|
||||
* @throws DeviceKeySigningException if signing fails
|
||||
*/
|
||||
suspend fun sign(data: ByteArray): ByteArray
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.tangem.lib.auth.devicekey
|
||||
|
||||
class DeviceKeySigningException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package com.tangem.lib.auth.devicekey.internal
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import arrow.core.None
|
||||
import arrow.core.Option
|
||||
import com.tangem.crypto.Secp256r1
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.Signature
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
|
||||
internal class DefaultDeviceKeyManager(
|
||||
private val keyStore: KeyStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : DeviceKeyManager {
|
||||
|
||||
override suspend fun generateIfMissing(): Boolean = withContext(dispatchers.io) {
|
||||
if (keyStore.containsAlias(KEY_ALIAS)) return@withContext false
|
||||
|
||||
try {
|
||||
generateKey()
|
||||
TangemLogger.i("Device key generated")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to generate device key", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPublicKey(): Option<ByteArray> = withContext(dispatchers.io) {
|
||||
Option.catch(
|
||||
recover = { e ->
|
||||
TangemLogger.e("Failed to get device public key", e)
|
||||
None
|
||||
},
|
||||
f = ::getPublicKeyBytes,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun sign(data: ByteArray): ByteArray = withContext(dispatchers.io) {
|
||||
try {
|
||||
val privateKey = keyStore.getKey(KEY_ALIAS, null)
|
||||
?: throw DeviceKeySigningException("Device key not found")
|
||||
|
||||
val signature = Signature.getInstance(SIGNATURE_ALGORITHM).apply {
|
||||
initSign(privateKey as java.security.PrivateKey)
|
||||
update(data)
|
||||
}
|
||||
|
||||
val derSignature = signature.sign()
|
||||
Secp256r1.toByte64(derSignature)
|
||||
} catch (e: DeviceKeySigningException) {
|
||||
TangemLogger.e("Device key signing failed", e)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Device key signing failed", e)
|
||||
throw DeviceKeySigningException("Signing failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateKey() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
try {
|
||||
initAndGenerateKeyPair(strongBox = true)
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.i("StrongBox unavailable, falling back to TEE", e)
|
||||
initAndGenerateKeyPair(strongBox = false)
|
||||
}
|
||||
} else {
|
||||
initAndGenerateKeyPair(strongBox = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initAndGenerateKeyPair(strongBox: Boolean) {
|
||||
val spec = buildKeyGenSpec(strongBox)
|
||||
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER)
|
||||
generator.initialize(spec)
|
||||
generator.generateKeyPair()
|
||||
}
|
||||
|
||||
private fun buildKeyGenSpec(strongBox: Boolean): KeyGenParameterSpec {
|
||||
val builder = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
|
||||
)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
|
||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||
.setUserAuthenticationRequired(false)
|
||||
|
||||
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
builder.setIsStrongBoxBacked(true)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun getPublicKeyBytes(): ByteArray {
|
||||
val cert = checkNotNull(keyStore.getCertificate(KEY_ALIAS)) { "Device key not found" }
|
||||
|
||||
val encoded = cert.publicKey.encoded
|
||||
check(encoded.size >= EC_UNCOMPRESSED_POINT_SIZE) {
|
||||
"Invalid encoded public key: expected at least $EC_UNCOMPRESSED_POINT_SIZE bytes, got ${encoded.size}"
|
||||
}
|
||||
val point = encoded.copyOfRange(encoded.size - EC_UNCOMPRESSED_POINT_SIZE, encoded.size)
|
||||
check(point[0] == UNCOMPRESSED_POINT_PREFIX) {
|
||||
"Invalid EC public key: expected uncompressed point prefix 0x04, got 0x${"%02x".format(point[0])}"
|
||||
}
|
||||
return point
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||
const val KEY_ALIAS = "tangem_device_key"
|
||||
const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
|
||||
const val EC_UNCOMPRESSED_POINT_SIZE = 65
|
||||
const val UNCOMPRESSED_POINT_PREFIX = 0x04.toByte()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.lib.auth.devicekey.internal
|
||||
|
||||
import arrow.core.None
|
||||
import arrow.core.Option
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
|
||||
|
||||
internal object DisabledDeviceKeyManager : DeviceKeyManager {
|
||||
|
||||
override suspend fun generateIfMissing(): Boolean = false
|
||||
|
||||
override suspend fun getPublicKey(): Option<ByteArray> = None
|
||||
|
||||
override suspend fun sign(data: ByteArray): ByteArray =
|
||||
throw DeviceKeySigningException("DeviceKeyManager is disabled: backend authentication feature toggle is off")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package com.tangem.lib.auth.devicekey.internal
|
||||
|
||||
import arrow.core.None
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeySigningException
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.KeyStore
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.Certificate
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DefaultDeviceKeyManagerTest {
|
||||
|
||||
private val keyStore: KeyStore = mockk(relaxed = true)
|
||||
private val dispatchers = TestingCoroutineDispatcherProvider()
|
||||
private val manager: DefaultDeviceKeyManager = DefaultDeviceKeyManager(keyStore, dispatchers)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(keyStore)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateIfMissing returns false when key already exists`() = runTest {
|
||||
every { keyStore.containsAlias(KEY_ALIAS) } returns true
|
||||
|
||||
val result = manager.generateIfMissing()
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateIfMissing returns false when generation fails`() = runTest {
|
||||
every { keyStore.containsAlias(KEY_ALIAS) } returns false
|
||||
|
||||
val keyPairGenerator = mockk<KeyPairGenerator>(relaxed = true)
|
||||
every { keyPairGenerator.generateKeyPair() } throws RuntimeException("keystore unavailable")
|
||||
|
||||
mockkStatic(KeyPairGenerator::class)
|
||||
every { KeyPairGenerator.getInstance("EC", "AndroidKeyStore") } returns keyPairGenerator
|
||||
|
||||
val result = manager.generateIfMissing()
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPublicKey returns last 65 bytes from encoded key`() = runTest {
|
||||
val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x04 }
|
||||
val x509Header = ByteArray(26) { 0x30 }
|
||||
val encoded = x509Header + rawPoint
|
||||
|
||||
val publicKey = mockk<java.security.PublicKey>()
|
||||
every { publicKey.encoded } returns encoded
|
||||
|
||||
val cert = mockk<Certificate>()
|
||||
every { cert.publicKey } returns publicKey
|
||||
every { keyStore.getCertificate(KEY_ALIAS) } returns cert
|
||||
|
||||
val result = manager.getPublicKey()
|
||||
|
||||
assertThat(result.getOrNull()).isEqualTo(rawPoint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPublicKey returns None when certificate not found`() = runTest {
|
||||
every { keyStore.getCertificate(KEY_ALIAS) } returns null
|
||||
|
||||
val result = manager.getPublicKey()
|
||||
|
||||
assertThat(result).isEqualTo(None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPublicKey returns None when point prefix is not uncompressed`() = runTest {
|
||||
val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x02 }
|
||||
val x509Header = ByteArray(26) { 0x30 }
|
||||
val encoded = x509Header + rawPoint
|
||||
|
||||
val publicKey = mockk<java.security.PublicKey>()
|
||||
every { publicKey.encoded } returns encoded
|
||||
|
||||
val cert = mockk<Certificate>()
|
||||
every { cert.publicKey } returns publicKey
|
||||
every { keyStore.getCertificate(KEY_ALIAS) } returns cert
|
||||
|
||||
val result = manager.getPublicKey()
|
||||
|
||||
assertThat(result).isEqualTo(None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign returns raw 64-byte signature`() = runTest {
|
||||
val data = "test data".toByteArray()
|
||||
val r = ByteArray(32) { 0x01 }
|
||||
val s = ByteArray(32) { 0x02 }
|
||||
val derSignature = buildDer(r, s)
|
||||
|
||||
val privateKey = mockk<PrivateKey>()
|
||||
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
|
||||
|
||||
val javaSig = mockk<java.security.Signature>()
|
||||
every { javaSig.initSign(privateKey) } returns Unit
|
||||
every { javaSig.update(data) } returns Unit
|
||||
every { javaSig.sign() } returns derSignature
|
||||
|
||||
mockkSignatureGetInstance(javaSig)
|
||||
|
||||
val result = manager.sign(data)
|
||||
|
||||
assertThat(result).hasLength(64)
|
||||
assertThat(result.copyOfRange(0, 32)).isEqualTo(r)
|
||||
assertThat(result.copyOfRange(32, 64)).isEqualTo(s)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign throws DeviceKeySigningException when key not found`() = runTest {
|
||||
every { keyStore.getKey(KEY_ALIAS, null) } returns null
|
||||
|
||||
val exception = assertThrows<DeviceKeySigningException> {
|
||||
manager.sign("data".toByteArray())
|
||||
}
|
||||
assertThat(exception.message).contains("Device key not found")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign wraps unexpected exception in DeviceKeySigningException`() = runTest {
|
||||
val privateKey = mockk<PrivateKey>()
|
||||
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
|
||||
|
||||
val javaSig = mockk<java.security.Signature>()
|
||||
every { javaSig.initSign(privateKey) } throws RuntimeException("hardware error")
|
||||
|
||||
mockkSignatureGetInstance(javaSig)
|
||||
|
||||
val exception = assertThrows<DeviceKeySigningException> {
|
||||
manager.sign("data".toByteArray())
|
||||
}
|
||||
assertThat(exception.message).isEqualTo("Signing failed")
|
||||
assertThat(exception.cause).isInstanceOf(RuntimeException::class.java)
|
||||
}
|
||||
|
||||
private fun mockkSignatureGetInstance(mock: java.security.Signature) {
|
||||
io.mockk.mockkStatic(java.security.Signature::class)
|
||||
every { java.security.Signature.getInstance("SHA256withECDSA") } returns mock
|
||||
}
|
||||
|
||||
private fun buildDer(r: ByteArray, s: ByteArray): ByteArray {
|
||||
val rTlv = byteArrayOf(0x02, r.size.toByte()) + r
|
||||
val sTlv = byteArrayOf(0x02, s.size.toByte()) + s
|
||||
val body = rTlv + sTlv
|
||||
return byteArrayOf(0x30, body.size.toByte()) + body
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_ALIAS = "tangem_device_key"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue