improve banner

This commit is contained in:
spaced4ndy
2026-07-29 18:19:34 +04:00
parent 993dceb3ce
commit 3ce918ca18
2 changed files with 98 additions and 23 deletions
@@ -11,18 +11,38 @@ import SimpleXChat
// A dismissible chat-list card promoting the badges purchase flow. Tapping the card opens the
// badges entry view (BadgesSupportSimplexView) the same first screen the settings row opens.
// The dismiss X hides the card permanently until DEFAULT_SUPPORTER_BANNER_SHOWN is reset.
// The dismiss X shows a confirmation alert (matching the Reachable-toolbar banner), and once
// dismissed the banner stays hidden until DEFAULT_SUPPORTER_BANNER_SHOWN is reset. The row-level
// show gate (chat count > 2 counting only visible chats) lives in ChatListView.
struct SupportSimpleXBanner: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme: ColorScheme
@AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false
@State private var showDismissAlert = false
let onTap: () -> Void
private let cardCornerRadius: CGFloat = 24
private let cardHeight: CGFloat = 72
private let cardHorizontalPadding: CGFloat = 16
private let heroWidth: CGFloat = 90
private let heroHeight: CGFloat = 110
// Trailing padding keeps the hero's right edge to the LEFT of the dismiss X's left edge
// (X's claim from the card right is trailing 16 + width 12 = 28pt, matching OneHandUICard,
// so hero trailing 32 gives a ~4pt gap between hero right and X left).
private let heroTrailingPadding: CGFloat = 32
private let textToHeroGap: CGFloat = 8
var body: some View {
if !supporterBannerShown {
// The card Button is the base (its natural size is the card rectangle, cardHeight tall)
// and the hero image is added as an .overlay so it can visually extend past the card top
// without changing the layout size. The dismiss X lives as a sibling in the outer
// ZStack, aligned .topTrailing so it sits at the card's top-right; the X label uses
// asymmetric vertical padding (12 top / 4 bottom) so the icon has a visible gap from
// the card top edge without being pinned to the very top.
ZStack(alignment: .topTrailing) {
Button(action: onTap) {
HStack(alignment: .center, spacing: 8) {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
Text("Support SimpleX")
.font(.headline)
@@ -31,26 +51,40 @@ struct SupportSimpleXBanner: View {
.font(.subheadline)
.foregroundColor(theme.colors.onBackground)
}
Spacer()
heroThumbnail()
Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap)
}
.padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.padding(.horizontal, cardHorizontalPadding)
.frame(height: cardHeight)
.background(gradientBackground())
.clipShape(RoundedRectangle(cornerRadius: 20))
.clipShape(RoundedRectangle(cornerRadius: cardCornerRadius))
}
.buttonStyle(.plain)
.overlay(alignment: .bottomTrailing) {
heroThumbnail()
.padding(.trailing, heroTrailingPadding)
.allowsHitTesting(false)
}
Button {
withAnimation { supporterBannerShown = true }
} label: {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(theme.colors.secondary)
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(6)
// X, sibling of the Button in ZStack, aligned to CARD top-right.
Image(systemName: "multiply")
.foregroundColor(theme.colors.secondary)
.frame(width: 12, height: 12)
.padding(.top, 12)
.padding(.bottom, 4)
.padding(.trailing, 16)
.padding(.leading, 4)
.contentShape(Rectangle())
.onTapGesture { showDismissAlert = true }
}
.zIndex(1)
.alert(isPresented: $showDismissAlert) {
Alert(
title: Text("Support SimpleX"),
message: Text("You can support SimpleX later in Settings."),
dismissButton: .default(Text("Ok")) {
withAnimation { supporterBannerShown = true }
}
)
}
}
}
@@ -58,19 +92,24 @@ struct SupportSimpleXBanner: View {
@ViewBuilder
private func heroThumbnail() -> some View {
#if SIMPLEX_ASSETS
// .resizable + .frame (no aspect modifier) stretches the asset to fill the frame exactly.
// The asset aspect (0.795) is close enough to the frame aspect (heroWidth/heroHeight 0.82)
// that horizontal stretching is barely perceptible (~3%), and this guarantees the phone
// illustration's bottom lines up with the frame bottom = card bottom (no whitespace gap
// from .scaledToFit centering, no overhang from .aspectRatio(.fill) leaking past the clip).
Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light")
.resizable()
.scaledToFit()
.frame(width: 90, height: 90)
.frame(width: heroWidth, height: heroHeight)
.clipShape(HeroBottomRightRoundedShape(cornerRadius: cardCornerRadius))
#else
Image("badge-supporter")
.resizable()
.scaledToFit()
.frame(width: 56, height: 56)
.frame(width: 48, height: 48)
.padding(.top, (cardHeight - 48) / 2)
#endif
}
// Matches the onboarding card gradient so the banner reads as part of the same visual family.
private func gradientBackground() -> some View {
let gp = OnboardingCardView.gradientPoints(aspectRatio: 4.0, scale: colorScheme == .light ? 1.2 : 1.5)
return LinearGradient(
@@ -81,6 +120,28 @@ struct SupportSimpleXBanner: View {
}
}
// Rounds only the bottom-right corner of the hero to match the card's rounded bottom-right
// leaves the top, left and bottom-left edges straight so the hero can extend above the card
// (top) and reach into the card without extra rounding on the sides.
private struct HeroBottomRightRoundedShape: Shape {
let cornerRadius: CGFloat
func path(in rect: CGRect) -> Path {
var p = Path()
p.move(to: CGPoint(x: rect.minX, y: rect.minY))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cornerRadius))
p.addQuadCurve(
to: CGPoint(x: rect.maxX - cornerRadius, y: rect.maxY),
control: CGPoint(x: rect.maxX, y: rect.maxY)
)
p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
p.closeSubpath()
return p
}
}
struct SupportSimpleXBanner_Previews: PreviewProvider {
static var previews: some View {
SupportSimpleXBanner(onTap: {})
@@ -374,6 +374,19 @@ struct ChatListView: View {
!addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations
}
// Count of user-facing conversations (excludes pending contact requests/connections, deleted
// chats and contact cards) used as the show gate for SupportSimpleXBanner so the banner only
// appears once the user actually has a few conversations in the list.
private var visibleChatCount: Int {
chatModel.chats.reduce(into: 0) { count, chat in
switch chat.chatInfo {
case let .direct(contact): if !contact.chatDeleted && !contact.isContactCard { count += 1 }
case .group, .local: count += 1
case .contactRequest, .contactConnection, .invalidJSON: break
}
}
}
private var hasConversations: Bool {
chatModel.chats.contains { chat in
switch chat.chatInfo {
@@ -425,12 +438,13 @@ struct ChatListView: View {
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if !supporterBannerShown {
if !supporterBannerShown && visibleChatCount > 2 {
SupportSimpleXBanner(onTap: { showBadgesSheet = true })
.padding(.vertical, 6)
.padding(.vertical, 3)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.zIndex(1)
}
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in