Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-21 17:19:45 +03:00
parent 7e6f015e5b
commit d282e2648f
5 changed files with 140 additions and 40 deletions

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -266,6 +267,10 @@ private fun WalletContent2(
val overlay = TangemTheme.colors2.overlay.overlayPrimary
// Root-coordinates bounds of the "Add & Manage" button per pager page, reported by the
// button itself, so the markets hint and tooltip can avoid covering it
val organizeButtonBounds = remember { mutableStateMapOf<Int, Rect>() }
HorizontalPager(
state = walletsPagerState,
userScrollEnabled = canPagerScroll,
@ -355,6 +360,15 @@ private fun WalletContent2(
promoBannersBlockComponent = promoBannersBlockComponent,
walletId = currentWalletId,
virtualAccountComponent = virtualAccountComponent,
onOrganizeButtonBoundsChange = remember(currentWalletIndex) {
{ bounds ->
if (bounds != null) {
organizeButtonBounds[currentWalletIndex] = bounds
} else {
organizeButtonBounds.remove(currentWalletIndex)
}
}
},
modifier = Modifier
.fillMaxSize()
.nestedScroll(behavior.nestedScrollConnection),
@ -371,6 +385,9 @@ private fun WalletContent2(
.fillMaxWidth(fraction = .6f)
.padding(bottom = peekHeight),
isVisible = isShowMarketsHint,
obstacleBounds = remember(currentWalletIndex) {
{ organizeButtonBounds[currentWalletIndex] }
},
)
}
}
@ -386,6 +403,9 @@ private fun WalletContent2(
sheetTopInset = TangemTheme.dimens2.x3,
bottomSheetState = bottomSheetState,
onCloseClick = state.onDismissMarketsTooltip,
obstacleBounds = remember(walletsPagerState, organizeButtonBounds) {
{ organizeButtonBounds[walletsPagerState.currentPage] }
},
)
}
}

View file

@ -1,53 +1,77 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.wallet.impl.R
/**
* Hint above the markets bottom sheet suggesting to pull it up to add tokens.
*
* The hint renders only when [isVisible] and its own on-screen bounds do not intersect
* [obstacleBounds] (e.g. the "Add & Manage" button resting nearby), so it never draws
* over other content.
*/
@Composable
internal fun MarketsHint(isVisible: Boolean, modifier: Modifier = Modifier) {
AnimatedVisibility(
modifier = modifier,
visible = isVisible,
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
exit = fadeOut(animationSpec = tween(durationMillis = 300)),
) {
Column(
verticalArrangement = Arrangement.spacedBy(space = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.markets_hint),
style = TangemTheme.typography2.bodyRegular15,
color = TangemTheme.colors2.text.neutral.tertiary,
textAlign = TextAlign.Center,
)
Icon(
modifier = Modifier.size(size = 24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
)
internal fun MarketsHint(isVisible: Boolean, modifier: Modifier = Modifier, obstacleBounds: () -> Rect? = { null }) {
var hintBounds by remember { mutableStateOf<Rect?>(null) }
val isObstacleInTheWay by remember(obstacleBounds) {
derivedStateOf {
val hint = hintBounds
val obstacle = obstacleBounds()
hint != null && obstacle != null && hint.overlaps(obstacle)
}
}
val isShown = isVisible && !isObstacleInTheWay
val alpha by animateFloatAsState(
targetValue = if (isShown) 1f else 0f,
animationSpec = tween(durationMillis = 300),
label = "marketsHintAlpha",
)
Column(
modifier = modifier
.onGloballyPositioned { hintBounds = it.boundsInRoot() }
.graphicsLayer { this.alpha = alpha }
.conditional(!isShown) { clearAndSetSemantics { } },
verticalArrangement = Arrangement.spacedBy(space = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResourceSafe(R.string.markets_hint),
style = TangemTheme.typography2.bodyRegular15,
color = TangemTheme.colors2.text.neutral.tertiary,
textAlign = TextAlign.Center,
)
Icon(
modifier = Modifier.size(size = 24.dp),
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
)
}
}
// region Preview

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@ -40,6 +41,17 @@ import com.tangem.feature.wallet.impl.R
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
/** Gap between the lifted tooltip and the obstacle below it. */
private val OBSTACLE_GAP = 4.dp
/**
* Onboarding tooltip for the markets bottom sheet, anchored right above the sheet peek.
*
* @param obstacleBounds provider of root-coordinates bounds of content the tooltip must not cover
* e.g. the "Add & Manage" button. When the anchored tooltip would overlap the obstacle, the tooltip
* is lifted to sit just above it.
*/
@Suppress("LongParameterList")
@Composable
internal fun MarketsTooltip(
availableHeight: Dp,
@ -48,17 +60,28 @@ internal fun MarketsTooltip(
onCloseClick: () -> Unit,
sheetTopInset: Dp,
modifier: Modifier = Modifier,
obstacleBounds: () -> Rect? = { null },
) {
val density = LocalDensity.current
val tooltipOffset by remember(availableHeight, sheetTopInset) {
var tooltipHeight by remember { mutableIntStateOf(0) }
val tooltipOffset by remember(availableHeight, sheetTopInset, obstacleBounds) {
derivedStateOf {
val bottomSheetOffset = try {
// Can throw exception during the first composition
with(density) { bottomSheetState.requireOffset().toDp() }
} catch (e: Exception) {
0.dp
// Sheet offset is in root coordinates: the scaffold content fills the screen edge to edge
val sheetTop = runCatching { bottomSheetState.requireOffset() }.getOrElse { 0f }
with(density) {
val anchoredBottom = sheetTop + sheetTopInset.toPx()
val anchoredTop = anchoredBottom - tooltipHeight
val obstacle = obstacleBounds()?.takeIf { tooltipHeight > 0 }
val isObstacleInTheWay = obstacle != null &&
obstacle.top < anchoredBottom && obstacle.bottom > anchoredTop
val bottom = if (isObstacleInTheWay) {
obstacle.top - OBSTACLE_GAP.toPx()
} else {
anchoredBottom
}
(bottom - availableHeight.toPx()).toDp()
}
bottomSheetOffset + sheetTopInset - availableHeight
}
}
@ -86,7 +109,10 @@ internal fun MarketsTooltip(
) + fadeIn(),
exit = fadeOut(),
) {
MarketsTooltipContent(onCloseClick = onCloseClick)
MarketsTooltipContent(
onCloseClick = onCloseClick,
modifier = Modifier.onSizeChanged { tooltipHeight = it.height },
)
}
}

View file

@ -1,7 +1,13 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.testTag
import com.tangem.core.ui.ds.button.TangemButton
import com.tangem.core.ui.extensions.resourceReference
@ -15,6 +21,8 @@ import com.tangem.features.tangempay.entity.TangemPayMainUM
import com.tangem.features.virtualaccount.main.component.VirtualAccountMainBlockComponent
import com.tangem.features.virtualaccount.main.entity.VirtualAccountMainUM
internal const val ORGANIZE_TOKENS_BUTTON_ITEM_KEY = "OrganizeTokensButton"
internal fun LazyListScope.nftCollections2(state: WalletUM, itemModifier: Modifier) {
(state as? WalletUM.Content)?.let { content ->
item(key = "NFTCollections", contentType = "NFTCollections") {
@ -26,12 +34,20 @@ internal fun LazyListScope.nftCollections2(state: WalletUM, itemModifier: Modifi
}
}
internal fun LazyListScope.organizeTokens2(state: WalletUM, itemModifier: Modifier) {
/**
* @param onButtonBoundsChange called with the button root-coordinates bounds on every placement change
* and with `null` when the button leaves composition
*/
internal fun LazyListScope.organizeTokens2(
state: WalletUM,
itemModifier: Modifier,
onButtonBoundsChange: (Rect?) -> Unit,
) {
val organizeButton = state.tokensListUM.organizeButtonUM
if (organizeButton != null) {
item(
key = "OrganizeTokensButton",
contentType = "OrganizeTokensButton",
key = ORGANIZE_TOKENS_BUTTON_ITEM_KEY,
contentType = ORGANIZE_TOKENS_BUTTON_ITEM_KEY,
) {
val hapticManager = LocalHapticManager.current
val testTag = if (organizeButton.text == resourceReference(R.string.main_add_and_manage_tokens)) {
@ -39,6 +55,12 @@ internal fun LazyListScope.organizeTokens2(state: WalletUM, itemModifier: Modifi
} else {
MainScreenTestTags.ORGANIZE_TOKENS_BUTTON
}
val currentOnButtonBoundsChange by rememberUpdatedState(onButtonBoundsChange)
DisposableEffect(Unit) {
onDispose { currentOnButtonBoundsChange(null) }
}
TangemButton(
buttonUM = organizeButton.copy(
onClick = {
@ -46,7 +68,9 @@ internal fun LazyListScope.organizeTokens2(state: WalletUM, itemModifier: Modifi
organizeButton.onClick()
},
),
modifier = itemModifier.testTag(testTag),
modifier = itemModifier
.onGloballyPositioned { currentOnButtonBoundsChange(it.boundsInRoot()) }
.testTag(testTag),
)
}
}

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
@ -40,6 +41,7 @@ internal fun WalletListContent(
tangemPayComponent: TangemPayMainBlockComponent,
virtualAccountComponent: VirtualAccountMainBlockComponent,
contentPadding: PaddingValues,
onOrganizeButtonBoundsChange: (Rect?) -> Unit,
modifier: Modifier = Modifier,
promoBannersBlockComponent: PromoBannersBlockComponent? = null,
walletId: String? = null,
@ -99,7 +101,11 @@ internal fun WalletListContent(
nftCollections2(state = currentWallet, itemModifier = itemModifier)
organizeTokens2(state = currentWallet, itemModifier = itemModifier)
organizeTokens2(
state = currentWallet,
itemModifier = itemModifier,
onButtonBoundsChange = onOrganizeButtonBoundsChange,
)
}
}