mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 11:10:30 +00:00
ui: one banner at a time in chat list (#7554)
This commit is contained in:
@@ -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(
|
||||
|
||||
+7
-62
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
+5
-2
@@ -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(),
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user