Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-04 16:51:20 +05:00
parent 1ffec50319
commit 95116dc872
16 changed files with 688 additions and 185 deletions

View file

@ -79,7 +79,7 @@ private fun DropdownMenuContent(
content: @Composable ColumnScope.() -> Unit,
) {
// Menu open/close animation.
val transition = updateTransition(expandedStates, "DropDownMenu")
val transition = rememberTransition(expandedStates, "DropDownMenu")
val scale by transition.animateFloat(
transitionSpec = {

View file

@ -48,9 +48,9 @@ fun TangemCheckbox(
isEnabled: Boolean = true,
) {
val shape = if (isRounded) {
RoundedCornerShape(TangemTheme.dimens2.x1)
} else {
CircleShape
} else {
RoundedCornerShape(TangemTheme.dimens2.x1)
}
Box(
modifier = modifier

View file

@ -0,0 +1,288 @@
package com.tangem.core.ui.ds.contextmenu
import android.content.res.Configuration
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.PopUpMenuTestTags
import dev.chrisbanes.haze.rememberHazeState
import kotlin.math.max
import kotlin.math.min
/**
* Just copy paste [DropdownMenu] from material3 with deleting vertical paddings.
*/
@Composable
fun TangemContextMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset.Zero,
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit,
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded
if (expandedStates.currentState || expandedStates.targetState) {
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
val density = LocalDensity.current
val popupPositionProvider = DropdownMenuPositionProvider(
offset,
density,
) { parentBounds, menuBounds ->
transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
}
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider,
properties = properties,
) {
DropdownMenuContent(
expandedStates = expandedStates,
transformOriginState = transformOriginState,
modifier = modifier,
content = content,
)
}
}
}
private const val IN_TRANSITION_DURATION = 120
private const val OUT_TRANSITION_DURATION = 75
@Suppress("ReusedModifierInstance", "MagicNumber")
@Composable
private fun DropdownMenuContent(
expandedStates: MutableTransitionState<Boolean>,
transformOriginState: MutableState<TransformOrigin>,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
// Menu open/close animation.
val transition = rememberTransition(expandedStates, "DropDownMenu")
val scale by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(
durationMillis = IN_TRANSITION_DURATION,
easing = LinearOutSlowInEasing,
)
} else {
// Expanded to dismissed.
tween(
durationMillis = 1,
delayMillis = OUT_TRANSITION_DURATION - 1,
)
}
},
label = "",
) { isExpanded ->
if (isExpanded) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0.8f
}
}
val alpha by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = 30)
} else {
// Expanded to dismissed.
tween(durationMillis = OUT_TRANSITION_DURATION)
}
},
label = "",
) { isExpanded ->
if (isExpanded) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0f
}
}
Card(
modifier = Modifier
.clip(RoundedCornerShape(TangemTheme.dimens2.x5))
.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = transformOriginState.value
},
elevation = CardDefaults.cardElevation(),
) {
Column(
modifier = modifier
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState())
.clip(RoundedCornerShape(TangemTheme.dimens2.x5))
.background(TangemTheme.colors2.contextMenu.background)
.testTag(PopUpMenuTestTags.CONTAINER),
content = content,
)
}
}
private fun calculateTransformOrigin(parentBounds: IntRect, menuBounds: IntRect): TransformOrigin {
val pivotX = when {
menuBounds.left >= parentBounds.right -> 0f
menuBounds.right <= parentBounds.left -> 1f
menuBounds.width == 0 -> 0f
else -> {
val intersectionCenter =
(max(parentBounds.left, menuBounds.left) + min(parentBounds.right, menuBounds.right)) / 2
(intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
}
}
val pivotY = when {
menuBounds.top >= parentBounds.bottom -> 0f
menuBounds.bottom <= parentBounds.top -> 1f
menuBounds.height == 0 -> 0f
else -> {
val intersectionCenter =
(max(parentBounds.top, menuBounds.top) + min(parentBounds.bottom, menuBounds.bottom)) / 2
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
}
}
return TransformOrigin(pivotX, pivotY)
}
private val MenuVerticalMargin = 48.dp
@Immutable
internal data class DropdownMenuPositionProvider(
val contentOffset: DpOffset,
val density: Density,
val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> },
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
// The min margin above and below the menu, relative to the screen.
val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() }
// The content offset specified using the dropdown offset parameter.
val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
// Compute horizontal position.
val toRight = anchorBounds.left + contentOffsetX
val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width
val toDisplayRight = windowSize.width - popupContentSize.width
val toDisplayLeft = 0
val x = if (layoutDirection == LayoutDirection.Ltr) {
sequenceOf(
toRight,
toLeft,
// If the anchor gets outside of the window on the left, we want to position
// toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight.
if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft,
)
} else {
sequenceOf(
toLeft,
toRight,
// If the anchor gets outside of the window on the right, we want to position
// toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft.
if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight,
)
}.firstOrNull {
it >= 0 && it + popupContentSize.width <= windowSize.width
} ?: toLeft
// Compute vertical position.
val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
val toCenter = anchorBounds.top - popupContentSize.height / 2
val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin
val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull { element ->
element >= verticalMargin &&
element + popupContentSize.height <= windowSize.height - verticalMargin
} ?: toTop
onPositionCalculated(
anchorBounds,
IntRect(
left = x,
top = y,
right = x + popupContentSize.width,
bottom = y + popupContentSize.height,
),
)
return IntOffset(x, y)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemContextMenu_Preview() {
TangemThemePreviewRedesign {
val hazeState = rememberHazeState()
Column(
modifier = Modifier
.fillMaxSize()
.background(TangemTheme.colors2.surface.level1)
.hazeSourceTangem(state = hazeState, zIndex = -1f),
) {
TangemContextMenu(
expanded = true,
onDismissRequest = { },
modifier = Modifier.hazeEffectTangem(state = hazeState),
) {
TangemContextMenuCheckboxItem(
title = stringReference("Sort by balance"),
isChecked = true,
onClick = {},
)
HorizontalDivider(
thickness = 0.5.dp,
color = TangemTheme.colors2.border.neutral.quaternary,
)
TangemContextMenuCheckboxItem(
title = stringReference("Group tokens"),
isChecked = false,
onClick = {},
)
}
}
}
}
// endregion

View file

@ -0,0 +1,46 @@
package com.tangem.core.ui.ds.contextmenu
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.checkbox.TangemCheckbox
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
/**
* Item with checkbox for [TangemContextMenu].
*/
@Composable
fun TangemContextMenuCheckboxItem(title: TextReference, isChecked: Boolean, onClick: () -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.width(238.dp)
.clickableSingle(onClick = onClick)
.padding(
vertical = TangemTheme.dimens2.x5,
horizontal = TangemTheme.dimens2.x4,
),
) {
Text(
text = title.resolveReference(),
style = TangemTheme.typography2.headingSemibold17,
color = TangemTheme.colors2.text.neutral.primary,
)
TangemCheckbox(
modifier = Modifier,
isRounded = true,
isChecked = isChecked,
onCheckedChange = { /* no-op */ },
)
}
}

View file

@ -5,19 +5,23 @@ import androidx.annotation.DrawableRes
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
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 com.tangem.core.ui.R
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
@ -30,11 +34,9 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
* @param title The title text to be displayed in the center of the top bar.
* @param modifier Modifier to be applied to the top bar.
* @param subtitle Optional subtitle text to be displayed below the title.
* @param startIconRes Optional drawable resource ID for the start icon.
* @param onStartContentClick Optional click action for the start icon.
* @param endIconRes Optional drawable resource ID for the end icon.
* @param onEndContentClick Optional click action for the end icon.
* @param isGhostButtons Flag to determine if ghost button styling should be applied.
* @param startActionUM Optional action data for the start action icon.
* @param endActionUM Optional action data for the end action icon.
* @param titleIconRes Optional drawable resource ID for the icon to be displayed next to the title.
*
[REDACTED_AUTHOR]
*/
@ -43,56 +45,95 @@ fun TangemTopBar(
modifier: Modifier = Modifier,
title: TextReference? = null,
subtitle: TextReference? = null,
@DrawableRes startIconRes: Int? = null,
onStartContentClick: (() -> Unit)? = null,
@DrawableRes endIconRes: Int? = null,
onEndContentClick: (() -> Unit)? = null,
startActionUM: TangemTopBarActionUM? = null,
endActionUM: TangemTopBarActionUM? = null,
@DrawableRes titleIconRes: Int? = null,
titleStyle: TextStyle = TangemTheme.typography2.headingSemibold17,
isGhostButtons: Boolean = false,
) {
TangemTopBarInner(
TangemTopBar(
title = title,
subtitle = subtitle,
titleIconRes = titleIconRes,
modifier = modifier,
content = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5),
) {
TangemTopBarTitle(title = title, titleIconRes = titleIconRes, titleStyle = titleStyle)
AnimatedVisibility(
visible = subtitle != null,
label = "Subtitle Visibility",
) {
val wrappedSubtitle = remember(this) { requireNotNull(subtitle) }
Text(
text = wrappedSubtitle.resolveAnnotatedReference(),
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.bodyRegular15,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
},
startContent = if (startIconRes != null) {
{ TangemTopBarIcon(iconRes = startIconRes) }
startContent = if (startActionUM != null) {
{ TangemTopBarActionContent(startActionUM) }
} else {
null
},
onStartContentClick = onStartContentClick,
endContent = if (endIconRes != null) {
{ TangemTopBarIcon(iconRes = endIconRes) }
endContent = if (endActionUM != null) {
{ TangemTopBarActionContent(endActionUM) }
} else {
null
},
onEndContentClick = onEndContentClick,
isGhostButtons = isGhostButtons,
)
}
/**
* A top bar composable that displays a title and optional start and end icons.
* [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8435-74860&m=dev)
*
* @param title The title text to be displayed in the center of the top bar.
* @param modifier Modifier to be applied to the top bar.
* @param subtitle Optional subtitle text to be displayed below the title.
*
[REDACTED_AUTHOR]
*/
@Composable
private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes: Int?, titleStyle: TextStyle) {
fun TangemTopBar(
modifier: Modifier = Modifier,
title: TextReference? = null,
subtitle: TextReference? = null,
@DrawableRes titleIconRes: Int? = null,
startContent: @Composable (() -> Unit)? = null,
endContent: @Composable (() -> Unit)? = null,
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(TangemTheme.dimens2.x16)
.padding(TangemTheme.dimens2.x4, TangemTheme.dimens2.x3),
) {
AnimatedVisibility(
visible = startContent != null,
modifier = Modifier.align(Alignment.CenterStart),
label = "Start Content Visibility",
) {
startContent?.invoke()
}
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5),
) {
TangemTopBarTitle(title = title, titleIconRes = titleIconRes)
AnimatedVisibility(
visible = subtitle != null,
label = "Subtitle Visibility",
) {
val wrappedSubtitle = remember(this) { requireNotNull(subtitle) }
Text(
text = wrappedSubtitle.resolveAnnotatedReference(),
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.bodyRegular15,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
AnimatedVisibility(
visible = endContent != null,
modifier = Modifier.align(Alignment.CenterEnd),
label = "End Content Visibility",
) {
endContent?.invoke()
}
}
}
@Composable
private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes: Int?) {
AnimatedVisibility(
visible = title != null,
label = "Title Visibility",
@ -127,7 +168,7 @@ private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes:
Text(
text = wrappedTitle.resolveAnnotatedReference(),
color = TangemTheme.colors2.text.neutral.primary,
style = titleStyle,
style = TangemTheme.typography2.headingSemibold17,
textAlign = TextAlign.Center,
maxLines = 1,
)
@ -136,12 +177,29 @@ private fun TangemTopBarTitle(title: TextReference?, @DrawableRes titleIconRes:
}
@Composable
private fun TangemTopBarIcon(@DrawableRes iconRes: Int) {
fun TangemTopBarActionContent(
actionUM: TangemTopBarActionUM,
modifier: Modifier = Modifier,
iconSize: Dp = TangemTheme.dimens2.x8,
) {
val background = lerp(
start = Color.Transparent,
stop = TangemTheme.colors2.button.backgroundSecondary,
fraction = actionUM.ghostModeProgress,
)
val padding = (TangemTheme.dimens2.x10 - iconSize) / 2
Icon(
imageVector = ImageVector.vectorResource(id = iconRes),
imageVector = ImageVector.vectorResource(id = actionUM.iconRes),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier.fillMaxSize(),
modifier = modifier
.size(TangemTheme.dimens2.x10)
.clip(CircleShape)
.conditional(actionUM.isActionable) { background(background) }
.conditionalCompose(actionUM.isActionable && actionUM.onClick != null) {
clickableSingle(onClick = requireNotNull(actionUM.onClick))
}
.padding(padding),
)
}
@ -154,13 +212,10 @@ private fun TangemTopBar_Preview(@PreviewParameter(PreviewProvider::class) param
TangemTopBar(
title = params.title,
subtitle = params.subtitle,
startIconRes = params.startIconRes,
endIconRes = params.endIconRes,
titleIconRes = params.titleIconRes,
isGhostButtons = params.isGhostButtons,
onStartContentClick = {},
onEndContentClick = {},
modifier = Modifier.background(TangemTheme.colors2.surface.level1),
startContent = params.startActionUM?.let { { TangemTopBarActionContent(it) } },
endContent = params.endActionUM?.let { { TangemTopBarActionContent(it) } },
)
}
}
@ -168,10 +223,9 @@ private fun TangemTopBar_Preview(@PreviewParameter(PreviewProvider::class) param
private class TangemTopBarPreviewData(
val title: TextReference? = null,
val subtitle: TextReference? = null,
val isGhostButtons: Boolean = false,
val titleIconRes: Int? = null,
val startIconRes: Int? = null,
val endIconRes: Int? = null,
val startActionUM: TangemTopBarActionUM? = null,
val endActionUM: TangemTopBarActionUM? = null,
)
private class PreviewProvider : PreviewParameterProvider<TangemTopBarPreviewData> {
@ -179,41 +233,79 @@ private class PreviewProvider : PreviewParameterProvider<TangemTopBarPreviewData
get() = sequenceOf(
TangemTopBarPreviewData(
title = stringReference("Title"),
startIconRes = R.drawable.ic_tangem_24,
endIconRes = R.drawable.ic_more_vertical_24,
isGhostButtons = true,
startActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
onClick = {},
isActionable = false,
),
endActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
onClick = {},
isActionable = false,
),
),
TangemTopBarPreviewData(
title = stringReference("Title"),
subtitle = stringReference("Subtitle"),
startIconRes = R.drawable.ic_tangem_24,
endIconRes = R.drawable.ic_more_vertical_24,
isGhostButtons = true,
startActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
onClick = {},
isActionable = true,
ghostModeProgress = 1f,
),
endActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
onClick = {},
isActionable = true,
ghostModeProgress = 1f,
),
),
TangemTopBarPreviewData(
title = stringReference("Title"),
subtitle = stringReference("Subtitle"),
titleIconRes = R.drawable.ic_tangem_24,
startIconRes = R.drawable.ic_tangem_24,
endIconRes = R.drawable.ic_more_vertical_24,
isGhostButtons = true,
startActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
onClick = {},
isActionable = false,
),
endActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
onClick = {},
isActionable = true,
ghostModeProgress = 1f,
),
),
TangemTopBarPreviewData(
subtitle = stringReference("Subtitle"),
titleIconRes = R.drawable.ic_tangem_24,
startIconRes = R.drawable.ic_tangem_24,
endIconRes = R.drawable.ic_more_vertical_24,
isGhostButtons = true,
startActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
onClick = {},
isActionable = true,
),
endActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
onClick = {},
isActionable = false,
),
),
TangemTopBarPreviewData(
title = stringReference("Title"),
endIconRes = R.drawable.ic_more_vertical_24,
isGhostButtons = true,
endActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
onClick = {},
isActionable = false,
),
),
TangemTopBarPreviewData(
title = stringReference("Title"),
startIconRes = R.drawable.ic_tangem_24,
isGhostButtons = true,
startActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
onClick = {},
isActionable = true,
ghostModeProgress = 1f,
),
),
TangemTopBarPreviewData(
title = combinedReference(
@ -238,8 +330,16 @@ private class PreviewProvider : PreviewParameterProvider<TangemTopBarPreviewData
},
),
),
startIconRes = R.drawable.ic_tangem_24,
endIconRes = R.drawable.ic_more_vertical_24,
startActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
onClick = {},
isActionable = true,
),
endActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
onClick = {},
isActionable = false,
),
),
)
}

View file

@ -0,0 +1,19 @@
package com.tangem.core.ui.ds.topbar
import androidx.annotation.DrawableRes
import androidx.annotation.FloatRange
/**
* User model for top bar action
*
* @property iconRes resource id of action icon
* @property isActionable if true, action will be clickable, otherwise - not
* @property onClick lambda be invoked when action component is clicked. If null, action will not be clickable
* @property ghostModeProgress progress of ghost mode animation, from 0f to 1f.
*/
data class TangemTopBarActionUM(
@param:DrawableRes val iconRes: Int,
val isActionable: Boolean = true,
val onClick: (() -> Unit)? = null,
@param:FloatRange(0.0, 1.0) val ghostModeProgress: Float = 0f,
)

View file

@ -1,95 +0,0 @@
package com.tangem.core.ui.ds.topbar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.platform.LocalDensity
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.TangemTheme
/**
* Internal top bar composable that arranges optional start, center, and end content.
* [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8435-74860&m=dev)
*
* @param modifier Modifier to be applied to the top bar.
* @param content Center content of the top bar.
* @param startContent Optional start content of the top bar.
* @param onStartContentClick Optional click action for the start content.
* @param endContent Optional end content of the top bar.
* @param onEndContentClick Optional click action for the end content.
* @param isGhostButtons Flag to determine if ghost button styling should be applied.
*
[REDACTED_AUTHOR]
*/
@Composable
internal fun TangemTopBarInner(
modifier: Modifier = Modifier,
content: (@Composable () -> Unit)? = null,
startContent: (@Composable () -> Unit)? = null,
onStartContentClick: (() -> Unit)? = null,
endContent: (@Composable () -> Unit)? = null,
onEndContentClick: (() -> Unit)? = null,
isGhostButtons: Boolean = false,
) {
val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(density = this).toDp() }
Box(
modifier = modifier
.height(TangemTheme.dimens2.x16 + statusBarHeight)
.fillMaxWidth()
.padding(top = statusBarHeight)
.padding(TangemTheme.dimens2.x4, TangemTheme.dimens2.x3),
) {
val iconModifier = Modifier
.size(TangemTheme.dimens2.x10)
.clip(RoundedCornerShape(TangemTheme.dimens2.x25))
.conditionalCompose(isGhostButtons) {
background(TangemTheme.colors2.button.backgroundSecondary)
}
AnimatedVisibility(
visible = startContent != null,
modifier = Modifier.align(Alignment.CenterStart),
label = "Start Content Visibility",
) {
Box(
modifier = iconModifier
.conditional(onStartContentClick != null) {
clickableSingle { onStartContentClick?.invoke() }
}
.conditionalCompose(isGhostButtons) { padding(TangemTheme.dimens2.x1) },
) {
startContent?.invoke()
}
}
AnimatedVisibility(
visible = content != null,
modifier = Modifier.align(Alignment.Center),
) {
content?.invoke()
}
AnimatedVisibility(
visible = endContent != null,
modifier = Modifier.align(Alignment.CenterEnd),
label = "End Content Visibility",
) {
Box(
modifier = iconModifier
.conditional(onEndContentClick != null) {
clickableSingle { onEndContentClick?.invoke() }
}
.conditionalCompose(isGhostButtons) { padding(TangemTheme.dimens2.x1) },
) {
endContent?.invoke()
}
}
}
}

View file

@ -20,6 +20,7 @@ object TangemColorPalette {
// endregion Dark
// region Dark Alpha
val Dark_05 = Color(0x0D1E1E1E)
val Dark_10 = Color(0x1A1E1E1E)
val Dark_20 = Color(0x331E1E1E)
val Dark_30 = Color(0x4D1E1E1E)

View file

@ -22,6 +22,7 @@ class TangemColors2 internal constructor(
val skeleton: Skeleton,
val markers: Markers,
val tabs: Tabs,
val contextMenu: ContextMenu,
) {
@Stable
@ -332,15 +333,23 @@ class TangemColors2 internal constructor(
class Neutral internal constructor(
primary: Color,
secondary: Color,
tertiary: Color,
quaternary: Color,
) {
var primary by mutableStateOf(primary)
private set
var secondary by mutableStateOf(secondary)
private set
var tertiary by mutableStateOf(tertiary)
private set
var quaternary by mutableStateOf(quaternary)
private set
fun update(other: Neutral) {
primary = other.primary
secondary = other.secondary
tertiary = other.tertiary
quaternary = other.quaternary
}
}
@ -612,6 +621,18 @@ class TangemColors2 internal constructor(
}
}
@Stable
class ContextMenu internal constructor(
background: Color,
) {
var background by mutableStateOf(background)
private set
fun update(other: ContextMenu) {
background = other.background
}
}
fun update(other: TangemColors2) {
text.update(other.text)
graphic.update(other.graphic)
@ -625,5 +646,6 @@ class TangemColors2 internal constructor(
skeleton.update(other.skeleton)
markers.update(other.markers)
tabs.update(other.tabs)
contextMenu.update(other.contextMenu)
}
}

View file

@ -79,6 +79,8 @@ private fun lightThemeColors2(): TangemColors2 {
neutral = TangemColors2.Border.Neutral(
primary = TangemColorPalette.Light3,
secondary = TangemColorPalette.Light5,
tertiary = TangemColorPalette.Light_10,
quaternary = TangemColorPalette.Dark_10,
),
status = TangemColors2.Border.Status(
accent = TangemColorPalette.Azure,
@ -186,6 +188,9 @@ private fun lightThemeColors2(): TangemColors2 {
backgroundTertiary = TangemColorPalette.White,
backgroundQuaternary = TangemColorPalette.Dark_20,
)
val contextMenu = TangemColors2.ContextMenu(
background = TangemColorPalette.Dark_05,
)
return TangemColors2(
text = text,
graphic = graphic,
@ -199,6 +204,7 @@ private fun lightThemeColors2(): TangemColors2 {
skeleton = skeleton,
markers = markers,
tabs = tabs,
contextMenu = contextMenu,
)
}
@ -241,6 +247,8 @@ private fun darkThemeColors2(): TangemColors2 {
neutral = TangemColors2.Border.Neutral(
primary = TangemColorPalette.Dark4,
secondary = TangemColorPalette.Dark4,
tertiary = TangemColorPalette.Light_10,
quaternary = TangemColorPalette.Light_10,
),
status = TangemColors2.Border.Status(
accent = TangemColorPalette.Azure,
@ -348,6 +356,9 @@ private fun darkThemeColors2(): TangemColors2 {
backgroundTertiary = TangemColorPalette.Light_10,
backgroundQuaternary = TangemColorPalette.Light_10,
)
val contextMenu = TangemColors2.ContextMenu(
background = TangemColorPalette.Light_10,
)
return TangemColors2(
text = text,
graphic = graphic,
@ -361,5 +372,6 @@ private fun darkThemeColors2(): TangemColors2 {
skeleton = skeleton,
markers = markers,
tabs = tabs,
contextMenu = contextMenu,
)
}

View file

@ -45,6 +45,11 @@ internal data class TangemTokenRowStory(
val onBalanceHiddenToggle: () -> Unit,
) : StoryBookPage
internal data class TangemContextMenuStory(
val isExpanded: Boolean,
val onExpandedChange: (Boolean) -> Unit,
) : StoryBookPage
internal data class TangemHeaderRowStory(
val isBalanceHidden: Boolean,
val onBalanceHiddenToggle: () -> Unit,

View file

@ -0,0 +1,17 @@
package com.tangem.feature.tester.presentation.storybook.page.contextmenu
import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemContextMenuStory>.build(): TangemContextMenuStory {
return TangemContextMenuStory(
isExpanded = false,
onExpandedChange = { expanded ->
updateStory { it.copy(isExpanded = expanded) }
},
)
}
internal val tangemContextMenuStoryFactory
get() = storyPageFactory(StateUpdater<TangemContextMenuStory>::build)

View file

@ -0,0 +1,80 @@
package com.tangem.feature.tester.presentation.storybook.page.contextmenu
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds.contextmenu.TangemContextMenu
import com.tangem.core.ui.ds.contextmenu.TangemContextMenuCheckboxItem
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import dev.chrisbanes.haze.rememberHazeState
import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory as TangemContextMenuStoryState
@Composable
internal fun TangemContextMenuStory(state: TangemContextMenuStoryState, modifier: Modifier = Modifier) {
val hazeState = rememberHazeState()
LazyColumn(
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
.statusBarsPadding()
.fillMaxSize()
.background(TangemTheme.colors2.surface.level1)
.hazeSourceTangem(state = hazeState, zIndex = -1f),
) {
item("context_menu") {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = "TangemContextMenu",
style = TangemTheme.typography2.headingSemibold17,
color = TangemTheme.colors2.text.neutral.primary,
)
Box {
PrimaryButton(
text = "Show Context Menu",
onClick = { state.onExpandedChange(true) },
)
TangemContextMenu(
expanded = state.isExpanded,
onDismissRequest = { state.onExpandedChange(false) },
offset = DpOffset(0.dp, 4.dp),
modifier = Modifier.hazeEffectTangem(hazeState),
) {
TangemContextMenuCheckboxItem(
title = TextReference.Str("Sort by balance"),
isChecked = true,
onClick = {},
)
HorizontalDivider(
thickness = 0.5.dp,
color = TangemTheme.colors2.border.neutral.quaternary,
)
TangemContextMenuCheckboxItem(
title = TextReference.Str("Group tokens"),
isChecked = false,
onClick = {},
)
}
}
}
}
}
}

View file

@ -18,12 +18,13 @@ import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory
import com.tangem.feature.tester.presentation.storybook.page.background.northernLightsStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.badge.tangemBadgeStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.buttons.buttonsStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.opportunities.opportunitiesBGStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.message.tangemMessageStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.checkbox.tangemCheckboxStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.contextmenu.tangemContextMenuStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.tangemHeaderRowStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.message.tangemMessageStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.opportunities.opportunitiesBGStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.tabs.tangemSegmentedPickerStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.tokenrow.tangemTokenRowStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.tangemHeaderRowStoryFactory
private data class StoryItem(val title: String, val factory: StoryPageFactory)
@ -37,6 +38,7 @@ private fun buildStories() = listOf(
StoryItem(title = "☑️ Checkbox", factory = tangemCheckboxStoryFactory),
StoryItem(title = "🪙 Token Row", factory = tangemTokenRowStoryFactory),
StoryItem(title = "📑 Header Row", factory = tangemHeaderRowStoryFactory),
StoryItem(title = "📋 Context Menu", factory = tangemContextMenuStoryFactory),
)
@Composable

View file

@ -12,6 +12,7 @@ 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.TangemContextMenuStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemSegmentedPickerStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory
@ -24,6 +25,7 @@ import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessa
import com.tangem.feature.tester.presentation.storybook.page.tabs.TangemSegmentedPickerStory
import com.tangem.feature.tester.presentation.storybook.page.tokenrow.TangemTokenRowStory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory
import com.tangem.feature.tester.presentation.storybook.page.contextmenu.TangemContextMenuStory
@Composable
internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) {
@ -45,6 +47,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
TangemSegmentedPickerStory -> TangemSegmentedPickerStory()
is TangemTokenRowStory -> TangemTokenRowStory(state = storyState)
is TangemHeaderRowStory -> TangemHeaderRowStory(state = storyState)
is TangemContextMenuStory -> TangemContextMenuStory(state = storyState)
}
}
}

View file

@ -1,23 +1,22 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.common
import android.content.res.Configuration
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalPowerSavingState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -46,23 +45,27 @@ internal fun WalletTopBar(
color = Color.Unspecified,
contentColor = Color.Unspecified,
modifier = Modifier.hazeEffectTangem {
progressive =
HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
},
) {
val isPowerSaving by LocalPowerSavingState.current.isPowerSavingModeEnabled.collectAsStateWithLifecycle()
val wrappedBalance = remember(behavior.state.collapsedFraction) {
if (behavior.state.collapsedFraction > VISIBILITY_THRESHOLD) walletBalance else null
}
TangemTopBar(
title = wrappedBalance,
startIconRes = R.drawable.ic_tangem_24,
endIconRes = R.drawable.ic_more_default_24,
onEndContentClick = topBarConfig.onDetailsClick,
isGhostButtons = !isPowerSaving,
startActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
isActionable = false,
),
endActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
isActionable = true,
onClick = topBarConfig.onDetailsClick,
ghostModeProgress = behavior.state.collapsedFraction,
),
modifier = Modifier
.statusBarsPadding()
.testTag(MainScreenTestTags.TOP_BAR),
)
}