Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-21 14:14:24 +02:00
parent 3e900b5d5b
commit 33efe2ae52
12 changed files with 56 additions and 101 deletions

View file

@ -323,7 +323,9 @@ fun BoxScope.FooterOverlay(
.fillMaxWidth()
.align(Alignment.BottomCenter),
) {
if (gradientHeight > 0.dp) {
val isGradientDisplayed = gradientHeight > 0.dp
if (isGradientDisplayed) {
Fade(
backgroundColor = fadeMax,
height = gradientHeight,
@ -333,7 +335,7 @@ fun BoxScope.FooterOverlay(
modifier = Modifier
.fillMaxWidth()
.height(measuredFooterHeight ?: 0.dp)
.background(fadeMax),
.background(if (isGradientDisplayed) fadeMax else Color.Transparent),
)
}
}

View file

@ -21,7 +21,6 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter
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
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
@ -39,9 +38,6 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.core.ui.utils.toPx
/** Default reserved height for the [TangemModalBottomSheetWithFooter] footer slot. */
val DEFAULT_FOOTER_HEIGHT: Dp = 80.dp
/**
* Modal bottom sheet with [content], [footer] and optional [title].
*
@ -54,12 +50,6 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheetWi
config: TangemBottomSheetConfig,
containerColor: Color = TangemTheme.colors.background.primary,
skipPartiallyExpanded: Boolean = true,
// FIXME([REDACTED_TASK_KEY]): temp workaround
// The footer slot reserves a fixed [DEFAULT_FOOTER_HEIGHT];
// callers whose footer differs must pass the real height explicitly. Exposed as an opt-in
// parameter so existing usages keep the previous behavior and nothing else is affected.
// Rework so the sheet measures the actual footer height internally and drops this parameter.
footerHeight: Dp = DEFAULT_FOOTER_HEIGHT,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit = {},
crossinline content: @Composable (T) -> Unit,
@ -75,7 +65,6 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheetWi
content = content,
footer = footer,
skipPartiallyExpanded = skipPartiallyExpanded,
footerHeight = footerHeight,
)
} else {
DefaultModalBottomSheetWithFooter<T>(
@ -86,7 +75,6 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemModalBottomSheetWi
footer = footer,
onBack = onBack,
skipPartiallyExpanded = skipPartiallyExpanded,
footerHeight = footerHeight,
)
}
}
@ -97,7 +85,6 @@ inline fun <reified T : TangemBottomSheetConfigContent> DefaultModalBottomSheetW
config: TangemBottomSheetConfig,
containerColor: Color,
skipPartiallyExpanded: Boolean = true,
footerHeight: Dp = DEFAULT_FOOTER_HEIGHT,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
@ -130,7 +117,6 @@ inline fun <reified T : TangemBottomSheetConfigContent> DefaultModalBottomSheetW
onBack = onBack,
content = content,
footer = footer,
footerHeight = footerHeight,
)
}
@ -149,7 +135,6 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheetW
config: TangemBottomSheetConfig,
containerColor: Color,
skipPartiallyExpanded: Boolean = true,
footerHeight: Dp = DEFAULT_FOOTER_HEIGHT,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
noinline footer: @Composable (BoxScope.(T) -> Unit)?,
@ -165,7 +150,6 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheetW
title = title,
content = content,
footer = footer,
footerHeight = footerHeight,
)
}
@ -177,7 +161,6 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
sheetState: TangemSheetState,
containerColor: Color,
modifier: Modifier = Modifier,
footerHeight: Dp = DEFAULT_FOOTER_HEIGHT,
noinline onBack: (() -> Unit)? = null,
crossinline title: @Composable BoxScope.(T) -> Unit,
crossinline content: @Composable (T) -> Unit,
@ -195,8 +178,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicModalBottomSheetWit
val isKeyboardOpen by rememberIsKeyboardVisible()
val buttonHeight by animateDpAsState(
targetValue = if (footer != null) footerHeight else 0.dp,
label = "FooterHeight",
targetValue = if (footer != null) 80.dp else 0.dp,
)
// Offset calculation for keyboard scroll adjustment:
// 1) Button height (footer)

View file

@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
@ -29,7 +28,6 @@ internal class ActivateCampaignBottomSheetComponent(
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
private val params: Params,
val onDismiss: () -> Unit,
val onFooterExtraHeightReady: (Dp) -> Unit,
) : ComposableModularContentComponent, AppComponentContext by appComponentContext {
private val model: ActivateCampaignsModel = getOrCreateModel(params)
@ -65,7 +63,6 @@ internal class ActivateCampaignBottomSheetComponent(
ActivateCampaignFooter(
footerUM = state.footerUM,
onFooterTextHeightReady = onFooterExtraHeightReady,
)
}

View file

@ -2,23 +2,31 @@ package com.tangem.features.promobanners.impl.campaigns.component
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.bottomsheets.LocalBottomSheetContentScrollable
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.DEFAULT_FOOTER_HEIGHT
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWithFooter
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.decompose.ComposableModularContentComponent
import com.tangem.core.ui.extensions.rememberLastNonNull
import com.tangem.core.ui.res.TangemTheme
@ -52,31 +60,44 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
val bottomSheet by bottomSheetSlot.subscribeAsState()
val activeChild = bottomSheet.child?.instance
val displayedChild = rememberLastNonNull(activeChild)
val footerExtraHeight by model.footerExtraHeightState.collectAsStateWithLifecycle()
TangemModalBottomSheetWithFooter<TangemBottomSheetConfigContent.Empty>(
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = activeChild != null,
onDismissRequest = model::onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors3.bg.primary,
footerHeight = DEFAULT_FOOTER_HEIGHT + footerExtraHeight,
type = TangemBottomSheetType.Modal,
onBack = model::onDismiss,
title = {
displayedChild?.Title()
},
content = {
Box(modifier = Modifier.animateContentSize()) {
displayedChild?.Content(modifier = Modifier)
val bottomInset = LocalTangemBottomSheetContentBottomInset.current
val bottomReserve = if (bottomInset > 0.dp) bottomInset else 16.dp
val scrollState = rememberScrollState()
val scrollableSignal = LocalBottomSheetContentScrollable.current
if (scrollableSignal != null) {
LaunchedEffect(scrollState) {
snapshotFlow { scrollState.canScrollForward || scrollState.canScrollBackward }
.collect { canScroll -> scrollableSignal.value = canScroll }
}
}
Column(modifier = Modifier.verticalScroll(state = scrollState)) {
Box(modifier = Modifier.animateContentSize()) {
displayedChild?.Content(modifier = Modifier)
}
if (scrollableSignal?.value != true) SpacerH32()
Spacer(modifier = Modifier.height(bottomReserve))
}
},
footer = {
Box(
modifier = Modifier
.navigationBarsPadding()
.padding(12.dp),
) {
Box(modifier = Modifier.padding(12.dp)) {
displayedChild?.Footer()
}
},
@ -102,7 +123,6 @@ internal class DefaultCampaignsComponent @AssistedInject constructor(
appComponentContext = context,
chooseTokenComponentFactory = chooseTokenComponentFactory,
onDismiss = model::onDismiss,
onFooterExtraHeightReady = model::onFooterExtraHeightReady,
params = ActivateCampaignBottomSheetComponent.Params(
campaignType = config.campaignType,
userWalletId = config.userWalletId,

View file

@ -12,6 +12,7 @@ import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.ToastMessage
@ -219,7 +220,17 @@ internal class ActivateCampaignsModel @Inject constructor(
null
}
val tokenItem = TokenItemStateConverter(appCurrency = appCurrency).convert(result.currency)
val tokenItem = TokenItemStateConverter(
appCurrency = appCurrency,
subtitleStateProvider = { status ->
TokenItemState.SubtitleState.TextContent(
value = resourceReference(
R.string.domain_receive_assets_onboarding_network_name,
wrappedList(status.currency.network.name),
),
)
},
).convert(result.currency)
uiState.update { state ->
state.copy(

View file

@ -1,7 +1,5 @@
package com.tangem.features.promobanners.impl.campaigns.model
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
@ -24,8 +22,6 @@ import com.tangem.features.promobanners.impl.campaigns.entity.toPromoCampaignId
import com.tangem.features.promobanners.impl.campaigns.service.CampaignsService
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -43,9 +39,6 @@ internal class CampaignsModel @Inject constructor(
val bottomSheetNavigation: SlotNavigation<CampaignsBottomSheetConfig> = SlotNavigation()
val footerExtraHeightState: StateFlow<Dp>
field = MutableStateFlow(0.dp)
init {
campaignsService.campaignFlow
.onEach { request ->
@ -89,22 +82,16 @@ internal class CampaignsModel @Inject constructor(
},
)
fun onFooterExtraHeightReady(height: Dp) {
footerExtraHeightState.value = height
}
fun onDismiss() {
bottomSheetNavigation.dismiss()
}
fun onActivated(campaignType: CampaignType) {
footerExtraHeightState.value = 0.dp
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.Enrolled(campaignType))
}
fun onAlreadyActivated(campaignType: CampaignType) {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
footerExtraHeightState.value = 0.dp
bottomSheetNavigation.activate(CampaignsBottomSheetConfig.AlreadyActivated(campaignType = campaignType))
}
}

View file

@ -78,8 +78,6 @@ internal fun ActivateCampaignContent(um: ActivateCampaignUM, modifier: Modifier
selectedAccount = um.selectedAccount,
onChooseTokenClick = um.onChooseTokenClick,
)
SpacerH32()
}
}

View file

@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -18,7 +16,6 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH12
@ -29,28 +26,17 @@ import com.tangem.features.promobanners.impl.campaigns.entity.FooterUM
import com.tangem.features.promobanners.impl.campaigns.entity.TermsUM
@Composable
internal fun ActivateCampaignFooter(
footerUM: FooterUM,
onFooterTextHeightReady: (Dp) -> Unit,
modifier: Modifier = Modifier,
) {
internal fun ActivateCampaignFooter(footerUM: FooterUM, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
val terms = footerUM.terms
if (terms != null) {
val density = LocalDensity.current
Text(
text = termsAnnotatedString(terms),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.onSizeChanged {
val termsBlockHeight = with(density) { it.height.toDp() } + 12.dp
onFooterTextHeightReady.invoke(termsBlockHeight)
},
modifier = Modifier.fillMaxWidth(),
)
SpacerH12()
@ -99,7 +85,6 @@ private fun Preview_ActivateCampaignFooter_WithTerms() {
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
onFooterTextHeightReady = {},
)
}
}
@ -114,7 +99,6 @@ private fun Preview_ActivateCampaignFooter_NoTerms() {
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
onFooterTextHeightReady = {},
)
}
}

View file

@ -54,8 +54,6 @@ internal fun AlreadyActivatedCampaignContent(message: TextReference, modifier: M
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
SpacerH32()
}
}

View file

@ -58,8 +58,6 @@ fun CampaignEnrolledMessageContent(message: TextReference, modifier: Modifier =
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH32()
}
}

View file

@ -65,7 +65,5 @@ fun NotActiveCampaignMessageContent(modifier: Modifier = Modifier) {
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
SpacerH32()
}
}

View file

@ -1,8 +1,6 @@
package com.tangem.features.promobanners.impl.campaigns.model
import androidx.compose.ui.unit.dp
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.models.wallet.UserWalletId
@ -108,11 +106,10 @@ internal class CampaignsModelTest {
}
@Test
fun `GIVEN footer height set WHEN onAlreadyActivated THEN analytics sent and height reset`() = runTest {
fun `WHEN onAlreadyActivated THEN analytics sent`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
model.onFooterExtraHeightReady(100.dp)
// Act
model.onAlreadyActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
@ -121,37 +118,20 @@ internal class CampaignsModelTest {
verify(exactly = 1) {
analyticsEventHandler.send(PromoCampaignsAnalyticsEvent.AlreadyEnrolledScreenOpened())
}
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
model.onDestroy()
}
@Test
fun `GIVEN footer height set WHEN onActivated THEN no analytics and height reset`() = runTest {
fun `WHEN onActivated THEN no analytics sent`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
model.onFooterExtraHeightReady(100.dp)
// Act
model.onActivated(CampaignType.WhaleSwapCashback(campaignId = "1"))
// Assert
verify { analyticsEventHandler wasNot Called }
assertThat(model.footerExtraHeightState.value).isEqualTo(0.dp)
model.onDestroy()
}
@Test
fun `WHEN onFooterExtraHeightReady THEN height state is updated`() = runTest {
// Arrange
val model = createModel(campaignFlow = emptyFlow())
advanceUntilIdle()
// Act
model.onFooterExtraHeightReady(42.dp)
// Assert
assertThat(model.footerExtraHeightState.value).isEqualTo(42.dp)
model.onDestroy()
}