Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-30 19:55:05 +05:00
parent 0b86264bbf
commit 6a082c1d58
19 changed files with 603 additions and 118 deletions

View file

@ -43,6 +43,7 @@ fun TangemContextMenu(
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset.Zero,
properties: PopupProperties = PopupProperties(focusable = true),
positionProvider: PopupPositionProvider? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val expandedStates = remember { MutableTransitionState(false) }
@ -51,7 +52,7 @@ fun TangemContextMenu(
if (expandedStates.currentState || expandedStates.targetState) {
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
val density = LocalDensity.current
val popupPositionProvider = DropdownMenuPositionProvider(
val popupPositionProvider = positionProvider ?: DropdownMenuPositionProvider(
offset,
density,
) { parentBounds, menuBounds ->
@ -249,6 +250,57 @@ internal data class DropdownMenuPositionProvider(
}
}
/**
* A [PopupPositionProvider] that centers the popup horizontally on the screen
* and positions it below the anchor. If there is not enough space below,
* it positions the popup above the anchor. If there is no space in either direction,
* it reports the required vertical shift via [onAnchorShiftRequired] so the caller
* can move the anchor upward to make room below.
*/
@Immutable
class CenteredContextMenuPositionProvider(
private val contentOffset: DpOffset,
private val density: Density,
private val onAnchorShiftRequired: (Int) -> Unit = {},
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
val x = (windowSize.width - popupContentSize.width) / 2
val yBelow = anchorBounds.bottom + contentOffsetY
val yAbove = anchorBounds.top - contentOffsetY - popupContentSize.height
val isFitsBelow = yBelow + popupContentSize.height <= windowSize.height
val isFitsAbove = yAbove >= 0
val y = when {
isFitsBelow -> {
onAnchorShiftRequired(0)
yBelow
}
isFitsAbove -> {
onAnchorShiftRequired(0)
yAbove
}
else -> {
// Neither fits — calculate how much the anchor must shift up
// so the popup fits below. Place popup at bottom edge of screen.
val desiredY = windowSize.height - popupContentSize.height
val shift = yBelow - desiredY
onAnchorShiftRequired(shift)
desiredY
}
}
return IntOffset(x, y)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)

View file

@ -1,18 +1,13 @@
package com.tangem.core.ui.ds.row.token
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -110,7 +105,7 @@ fun TangemTokenRow(
.fillMaxWidth(),
)
},
modifier = modifier.tokenClickable(tokenRowUM = tokenRowUM),
modifier = modifier,
)
}
@ -198,36 +193,10 @@ fun TangemTokenRow(
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
)
},
modifier = modifier.tokenClickable(tokenRowUM = tokenRowUM),
modifier = modifier,
)
}
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.tokenClickable(tokenRowUM: TangemTokenRowUM): Modifier = composed {
val hapticFeedback = LocalHapticFeedback.current
val onClick = tokenRowUM.onItemClick
val onLongClick = tokenRowUM.onItemLongClick
val onHapticLongClick = if (onLongClick != null) {
{
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
}
} else {
null
}
when {
onClick == null && onLongClick == null -> this
onClick == null && onLongClick != null -> combinedClickable(onClick = {}, onLongClick = onHapticLongClick)
onClick != null && onLongClick == null -> combinedClickable(onClick = onClick)
onClick != null && onLongClick != null -> {
combinedClickable(onClick = onClick, onLongClick = onHapticLongClick)
}
else -> this
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)

View file

@ -2,6 +2,7 @@ package com.tangem.core.ui.ds.row.token
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.geometry.Offset
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeState
import com.tangem.core.ui.ds.badge.TangemBadgeUM
@ -11,7 +12,9 @@ import com.tangem.core.ui.ds.row.internal.TangemRowTailUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.serialization.Serializable
@Serializable
@Immutable
sealed class TangemTokenRowUM : TangemRowUM {
@ -43,11 +46,12 @@ sealed class TangemTokenRowUM : TangemRowUM {
abstract val onItemClick: (() -> Unit)?
/** Callback which will be called when an item is long clicked */
abstract val onItemLongClick: (() -> Unit)?
abstract val onItemLongClick: ((Offset, TangemTokenRowUM) -> Any)?
/**
* Content state of [TangemTokenRowUM]
*/
@Serializable
data class Content(
override val id: String,
override val headIconUM: TangemIconUM.Currency,
@ -58,12 +62,13 @@ sealed class TangemTokenRowUM : TangemRowUM {
override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty,
override val tailUM: TangemRowTailUM = TangemRowTailUM.Empty,
override val onItemClick: (() -> Unit)?,
override val onItemLongClick: (() -> Unit)?,
override val onItemLongClick: ((Offset, TangemTokenRowUM) -> Any)?,
) : TangemTokenRowUM()
/**
* Loading state of [TangemTokenRowUM]
*/
@Serializable
data class Loading(
override val id: String,
override val headIconUM: TangemIconUM.Currency = TangemIconUM.Currency(CurrencyIconState.Loading),
@ -75,12 +80,13 @@ sealed class TangemTokenRowUM : TangemRowUM {
override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty
override val tailUM: TangemRowTailUM = TangemRowTailUM.Empty
override val onItemClick: (() -> Unit)? = null
override val onItemLongClick: (() -> Unit)? = null
override val onItemLongClick: ((Offset, TangemTokenRowUM) -> Unit)? = null
}
/**
* Loading state of [TangemTokenRowUM]
*/
@Serializable
data class Empty(
override val id: String,
) : TangemTokenRowUM() {
@ -92,12 +98,13 @@ sealed class TangemTokenRowUM : TangemRowUM {
override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty
override val tailUM: TangemRowTailUM = TangemRowTailUM.Empty
override val onItemClick: (() -> Unit)? = null
override val onItemLongClick: (() -> Unit)? = null
override val onItemLongClick: ((Offset, TangemTokenRowUM) -> Unit)? = null
}
/**
* Actionable state of [TangemTokenRowUM]
*/
@Serializable
data class Actionable(
override val id: String,
override val headIconUM: TangemIconUM.Currency,
@ -105,16 +112,17 @@ sealed class TangemTokenRowUM : TangemRowUM {
override val subtitleUM: SubtitleUM,
override val tailUM: TangemRowTailUM,
override val onItemClick: (() -> Unit)?,
override val onItemLongClick: (() -> Unit)?,
override val onItemLongClick: ((Offset, TangemTokenRowUM) -> Unit)?,
override val topEndContentUM: EndContentUM = EndContentUM.Empty,
override val bottomEndContentUM: EndContentUM = EndContentUM.Empty,
) : TangemTokenRowUM() {
override val promoBannerUM: PromoBannerUM = PromoBannerUM.Empty
}
@Serializable
@Immutable
sealed class TitleUM {
@Serializable
data class Content(
val text: TextReference,
val hasPending: Boolean = false,
@ -123,16 +131,20 @@ sealed class TangemTokenRowUM : TangemRowUM {
val onBadgeClick: (() -> Unit)? = null,
) : TitleUM()
@Serializable
data object Loading : TitleUM()
@Serializable
data object Placeholder : TitleUM()
@Serializable
data object Empty : TitleUM()
}
@Serializable
@Immutable
sealed class SubtitleUM {
@Serializable
data class Content(
val text: TextReference,
val isAvailable: Boolean = true,
@ -142,16 +154,20 @@ sealed class TangemTokenRowUM : TangemRowUM {
val badge: TangemBadgeUM? = null,
) : SubtitleUM()
@Serializable
data object Loading : SubtitleUM()
@Serializable
data object Placeholder : SubtitleUM()
@Serializable
data object Empty : SubtitleUM()
}
@Serializable
@Immutable
sealed class EndContentUM {
@Serializable
data class Content(
val text: TextReference,
val isAvailable: Boolean = true,
@ -161,13 +177,17 @@ sealed class TangemTokenRowUM : TangemRowUM {
val priceChangeUM: PriceChangeState = PriceChangeState.Unknown,
) : EndContentUM()
@Serializable
data object Loading : EndContentUM()
@Serializable
data object Placeholder : EndContentUM()
@Serializable
data object Empty : EndContentUM()
}
@Serializable
@Immutable
sealed class PromoBannerUM {
data class Content(
@ -184,6 +204,7 @@ sealed class TangemTokenRowUM : TangemRowUM {
}
}
@Serializable
data object Empty : PromoBannerUM()
}
}

View file

@ -128,7 +128,7 @@ object TangemTokenRowPreviewData {
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
tailUM = TangemRowTailUM.Empty,
onItemClick = {},
onItemLongClick = {},
onItemLongClick = { _, _ -> },
)
val defaultEllipsisState: TangemTokenRowUM.Content
@ -155,7 +155,7 @@ object TangemTokenRowPreviewData {
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
tailUM = TangemRowTailUM.Empty,
onItemClick = {},
onItemLongClick = {},
onItemLongClick = { _, _ -> },
)
val tokenState: TangemTokenRowUM.Content
@ -169,7 +169,7 @@ object TangemTokenRowPreviewData {
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
tailUM = TangemRowTailUM.Empty,
onItemClick = {},
onItemLongClick = {},
onItemLongClick = { _, _ -> },
)
val customTokenState: TangemTokenRowUM.Content
@ -183,7 +183,7 @@ object TangemTokenRowPreviewData {
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
tailUM = TangemRowTailUM.Empty,
onItemClick = {},
onItemLongClick = {},
onItemLongClick = { _, _ -> },
)
val draggableState: TangemTokenRowUM.Actionable
@ -194,7 +194,7 @@ object TangemTokenRowPreviewData {
subtitleUM = subtitleUM,
tailUM = TangemRowTailUM.Draggable(R.drawable.ic_drag_24),
onItemClick = {},
onItemLongClick = {},
onItemLongClick = { _, _ -> },
)
val draggableStateV2: TangemTokenRowUM.Actionable
@ -207,7 +207,7 @@ object TangemTokenRowPreviewData {
bottomEndContentUM = bottomEndContentUM,
tailUM = TangemRowTailUM.Draggable(R.drawable.ic_drag_24),
onItemClick = {},
onItemLongClick = {},
onItemLongClick = { _, _ -> },
)
val loadingState: TangemTokenRowUM.Loading
@ -252,7 +252,7 @@ object TangemTokenRowPreviewData {
priceChangeUM = priceChangeState,
),
onItemClick = {},
onItemLongClick = {},
onItemLongClick = { _, _ -> },
)
val accountLetterState: TangemTokenRowUM.Content