ui: one banner at a time in chat list (#7554)

This commit is contained in:
spaced4ndy
2026-09-21 16:51:14 +00:00
committed by GitHub
parent 9a583d394f
commit a3ac420e91
11 changed files with 61 additions and 112 deletions
@@ -130,6 +130,8 @@ object BadgeModel {
this.rhId.value == rhId && this.userId.value == userId
}
enum class ChatListBanner { BadgeExpired, BadgePitch, GetStake }
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
* */
@@ -199,6 +201,12 @@ object ChatModel {
// Needed to apply black color to left/right cutout area on Android
val fullscreenGalleryVisible = mutableStateOf(false)
// the banner kind the chat list showed this app session: it keeps the slot until restart, so dismissing it never puts
// another in its place; only the badge alert shows regardless. Set while rendering, so not a state.
var chatListBanner: ChatListBanner? = null
fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner
// preferences
val notificationPreviewMode by lazy {
mutableStateOf(
@@ -2,32 +2,23 @@ package chat.simplex.common.views.badges
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
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.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.BuildConfigCommon
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.BannerDismissButton
import chat.simplex.common.views.chatlist.bannerCard
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.darkStops
import chat.simplex.common.views.newchat.gradientPoints
import chat.simplex.common.views.newchat.lightStops
import chat.simplex.res.MR
@Composable
@@ -37,13 +28,9 @@ fun SupportSimpleXBanner(
onTap: () -> Unit,
onDismiss: () -> Unit
) {
val cardCornerRadius = 16.dp
// grows linearly with system font but never shrinks below the default so small-font users see the
// same baseline; the card Row uses heightIn(min = cardHeight) and grows further when 2-line text
// wraps at very large fonts. Hero stays fixed so its above-card overhang shrinks at very large fonts.
// the card's own height, for centring the fallback hero; hero stays fixed so its above-card
// overhang shrinks at very large fonts
val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(72.dp)
// matches OneHandUICard's segment icon leading so the text aligns with it in the list
val cardLeadingPadding = 16.dp
val cardTrailingPadding = 8.dp
val heroWidth = 110.dp
// shorter than the natural drawn height so ContentScale.Crop slices the phone body at card bottom
@@ -52,24 +39,15 @@ fun SupportSimpleXBanner(
val heroTrailingPadding = 28.dp
val textToHeroGap = 6.dp
val isDark = isInDarkTheme()
var cardSize by remember { mutableStateOf(IntSize.Zero) }
val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) }
// Layout sizes to the card; hero is placed at y = cardHeight - heroHeight (negative → hero
// overhangs above card at normal fonts, 0/positive → hero fits inside card at large fonts).
Layout(content = {
Box(Modifier.fillMaxWidth()) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = cardHeight)
.clip(RoundedCornerShape(cardCornerRadius))
.background(brush)
.clickable(onClick = onTap)
.onSizeChanged { cardSize = it }
.bannerCard(onTap)
.padding(
start = cardLeadingPadding,
start = 16.dp,
end = cardTrailingPadding + heroWidth + heroTrailingPadding + textToHeroGap,
top = 12.dp,
bottom = 12.dp
@@ -95,19 +73,7 @@ fun SupportSimpleXBanner(
}
}
// Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area.
Icon(
painterResource(MR.images.ic_close),
contentDescription = stringResource(MR.strings.icon_descr_close_button),
tint = if (isDark) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 4.dp, top = 4.dp)
.clip(CircleShape)
.clickable(onClick = onDismiss)
.padding(8.dp)
.size(16.dp)
)
BannerDismissButton(Modifier.align(Alignment.TopEnd), onDismiss)
}
HeroThumbnail(
@@ -149,24 +115,3 @@ private fun HeroThumbnail(heroWidth: Dp, heroVisibleHeight: Dp, cardHeight: Dp,
)
}
}
// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than
// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region.
private fun gradientBrush(isDark: Boolean, size: IntSize): Brush {
val stops = if (isDark) darkStops else lightStops
if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops)
val w = size.width.toFloat()
val h = size.height.toFloat()
val startScale = if (isDark) 3.0f else 2.5f
val endScale = if (isDark) 2.1f else 1.7f
val gp = gradientPoints(h / w, 1.0f)
val sx = 0.5f + (gp.startX - 0.5f) * startScale
val sy = 0.5f + (gp.startY - 0.5f) * startScale
val ex = 0.5f + (gp.endX - 0.5f) * endScale
val ey = 0.5f + (gp.endY - 0.5f) * endScale
return Brush.linearGradient(
colorStops = stops,
start = Offset(sx * w, sy * h),
end = Offset(ex * w, ey * h)
)
}
@@ -1046,6 +1046,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
val alert = BadgeModel.alert.value
if (supportEnded() && alert != null) {
item {
SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeExpired }
Box(Modifier.zIndex(1f).padding(16.dp)) {
SupportSimpleXBanner(
title = stringResource(MR.strings.badges_support_ended),
@@ -1055,8 +1056,9 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
)
}
}
} else if (!supporterBannerShown.value && !hasShownBadge() && chatModel.chats.value.size > 3) {
} else if (chatModel.bannerSlotFree(ChatListBanner.BadgePitch) && !supporterBannerShown.value && noShownBadge() && chatModel.chats.value.size > 3) {
item {
SideEffect { chatModel.chatListBanner = ChatListBanner.BadgePitch }
Box(Modifier.zIndex(1f).padding(16.dp)) {
SupportSimpleXBanner(
onTap = { ModalManager.start.showCustomModal { close -> BadgesView(close) } },
@@ -1064,8 +1066,9 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
)
}
}
} else if (crowdfunding && !getStakeBannerDismissed.value) {
} else if (chatModel.bannerSlotFree(ChatListBanner.GetStake) && crowdfunding && !getStakeBannerDismissed.value) {
item {
SideEffect { chatModel.chatListBanner = ChatListBanner.GetStake }
Box(Modifier.zIndex(1f).padding(16.dp)) {
GetStakeBanner(
showDismiss = getStakeBannerTapped.value && chatModel.chats.value.isNotEmpty(),
@@ -58,8 +58,10 @@ fun shouldShowOnboarding(): Boolean {
fun supportEnded(): Boolean =
BadgeModel.alert.value?.kind == BadgeAlertKind.SupportEnded && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)
fun hasShownBadge(): Boolean =
BadgeModel.badgeState.value?.shown == true && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)
// false until the badge state loads: if the pitch rendered before that, it would lock the slot, and a supporter's badge
// arriving a moment later would hide it, leaving the slot empty for the session
fun noShownBadge(): Boolean =
BadgeModel.badgeState.value?.shown != true && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)
fun hasConversations(chats: List<Chat>): Boolean =
chats.any { chat ->
@@ -420,7 +422,7 @@ fun ConnectOnboardingView() {
}
val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state }
val showGetStakeBanner = crowdfundingAvailable() && !getStakeBannerDismissed.value
val showGetStakeBanner = chatModel.bannerSlotFree(ChatListBanner.GetStake) && crowdfundingAvailable() && !getStakeBannerDismissed.value
// on desktop the pages span the window, but the banner keeps the width it has in the chat list
val bannerMaxWidth = if (appPlatform.isDesktop) DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier else Dp.Unspecified
val content = @Composable {
@@ -429,6 +431,7 @@ fun ConnectOnboardingView() {
pager()
}
if (showGetStakeBanner) {
SideEffect { chatModel.chatListBanner = ChatListBanner.GetStake }
Box(Modifier.align(Alignment.CenterHorizontally).widthIn(max = bannerMaxWidth).padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 8.dp)) {
GetStakeBanner(
showDismiss = false,