This commit is contained in:
spaced4ndy
2026-07-30 23:53:12 +04:00
parent 90a8793d7c
commit 85501e2286
4 changed files with 68 additions and 120 deletions
@@ -9,11 +9,9 @@
import SwiftUI
import SimpleXChat
// Shows the user's avatar and display name with a synthesized preview badge at the selected
// level, using the same NameWithBadge helper that renders badges everywhere else in the app
// so the preview matches what will actually appear on the user's name. Any picker affordance
// (e.g. a chevron) is supplied by the caller via `trailing`, and is laid out next to the
// name row without leaking into this view.
// User's avatar + name with a preview badge at the selected level, rendered via NameWithBadge
// so the preview matches how the badge appears on the name elsewhere. Callers can inject a
// picker affordance (e.g. a chevron) into the name row via `trailing`.
struct BadgeUserPreview<Trailing: View>: View {
@EnvironmentObject var chatModel: ChatModel
let level: BadgeLevel
@@ -9,10 +9,8 @@
import SwiftUI
import SimpleXChat
// Entry point for badges management. The subsequent screens (level selection, pay, redeem code,
// how it works) are pushed via NavigationLink from this view, so the enclosing NavigationView
// either the settings NavigationView or the sheet NavigationView presented from the chat list
// banner provides the sliding animation.
// Entry point for badges management. Subsequent screens push via NavigationLink; the enclosing
// NavigationView (settings or the chat-list banner sheet) provides the sliding animation.
struct BadgesSupportSimplexView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme: ColorScheme
@@ -21,13 +19,7 @@ struct BadgesSupportSimplexView: View {
@State private var redeemCodeActive = false
var body: some View {
// TODO [badges] when the state machine lands, gate on user badge status:
// - no badge this view (support prompt)
// - active badge a "Manage your badge" view
//
// No ScrollView here matches SimpleXInfo/YourNetworkView: on tight screens the
// .scaledToFit hero shrinks to fit the vertical space left by the fixed-height title,
// body, info button and CTAs, instead of pushing content off-screen.
// TODO [badges] gate on user badge status (no badge this view, active "Manage your badge")
GeometryReader { g in
VStack(alignment: .center, spacing: 16) {
Text("Support SimpleX")
@@ -64,12 +56,8 @@ struct BadgesSupportSimplexView: View {
.padding(.horizontal, 25)
.padding(.top, 28)
.padding(.bottom, 20)
// .frame(height:) instead of .frame(minHeight:) locks the VStack to the
// proposed geometry. In the onboarding flow (full-screen presentation) minHeight is
// enough because the parent caps at the screen; inside the banner sheet's NavigationView
// the parent DOESN'T cap, so a minHeight-only VStack expands past the visible area,
// which both makes the hero gigantic and lets the sheet interpret the overflow as
// scroll content. Locking to the geometry keeps the layout inside the visible bounds.
// .frame(height:) not minHeight inside the banner sheet's NavigationView minHeight
// would let the VStack expand past the visible area and inflate the hero.
.frame(height: g.size.height)
}
.frame(maxHeight: .infinity)
@@ -123,9 +111,8 @@ struct BadgesSupportSimplexView: View {
}
}
// Hero image reused across badges views and the WhatsNewView v7.1 entry. Falls back to a gradient
// card carrying the small supporter badge glyph when SIMPLEX_ASSETS is not defined matching the
// onboarding placeholder convention.
// Hero image reused across badges views and WhatsNewView v7.1. Fallback (no SIMPLEX_ASSETS) is a
// gradient card carrying the small supporter badge glyph.
struct PhoneSupporterHero: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme: ColorScheme
@@ -9,105 +9,79 @@
import SwiftUI
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 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.
// Dumb chat-list card promoting the badges purchase flow. Both the show gate and the dismissal
// persistence live in ChatListView; this view just renders and reports taps.
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
let onDismiss: () -> Void
private let cardCornerRadius: CGFloat = 16
private let cardHeight: CGFloat = 72
// 16pt matches OneHandUICard's segment icon .padding(.leading, 16) so the banner's text
// aligns with the icon column of the Reachable-toolbar card above it in the list.
// matches OneHandUICard's segment icon leading so the text aligns with it in the list
private let cardLeadingPadding: CGFloat = 16
private let cardTrailingPadding: CGFloat = 8
private let heroWidth: CGFloat = 110
// Illustration is DRAWN at NATURAL aspect (0.795) via .aspectRatio(.fill) for heroWidth 110
// that gives a 110×138 image. The visible slot is heroWidth × heroVisibleHeight (110×108), top-
// aligned; the ~30pt of natural image below the slot is cut by .clipped(). Crop line lands
// ~78% into the phone body.
// shorter than the natural drawn height so .clipped() slices the phone body at card bottom
private let heroVisibleHeight: CGFloat = 108
// 28 (was 32) hero right sits exactly at X's left edge (X's own trailing 16 + width 12 = 28).
// No overlap, no gap. Required so the wider 110pt hero still leaves subtitle room.
// hero right edge sits exactly at the dismiss X's left edge (X trailing 16 + width 12)
private let heroTrailingPadding: CGFloat = 28
// 6 (was 8) small reduction to reclaim text width with the bigger hero.
private let textToHeroGap: CGFloat = 6
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(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
Text("Support SimpleX")
.font(.headline)
.foregroundColor(theme.colors.primary)
Text("Get badge + files up to 5GB")
.font(.subheadline)
.foregroundColor(theme.colors.onBackground)
}
Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap)
// Card is the Button; hero is an overlay so it can extend above the card top without
// affecting layout size. Dismiss X is a ZStack sibling anchored to the card's top-right.
ZStack(alignment: .topTrailing) {
Button(action: onTap) {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
Text("Support SimpleX")
.font(.headline)
.foregroundColor(theme.colors.primary)
Text("Get badge + files up to 5GB")
.font(.subheadline)
.foregroundColor(theme.colors.onBackground)
}
.padding(.leading, cardLeadingPadding)
.padding(.trailing, cardTrailingPadding)
.frame(height: cardHeight)
.background(gradientBackground())
.clipShape(RoundedRectangle(cornerRadius: cardCornerRadius))
}
.buttonStyle(.plain)
.overlay(alignment: .bottomTrailing) {
// Hero bottom-anchored to card bottom via .overlay(.bottomTrailing); the
// illustration extends UP above the card top only. No .offset nothing must
// draw below the card. .clipped() inside heroThumbnail is a hard rectangular
// clip at the hero's own frame boundary to guarantee that.
heroThumbnail()
.padding(.trailing, heroTrailingPadding)
.allowsHitTesting(false)
Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap)
}
.padding(.leading, cardLeadingPadding)
.padding(.trailing, cardTrailingPadding)
.frame(height: cardHeight)
.background(gradientBackground())
.clipShape(RoundedRectangle(cornerRadius: cardCornerRadius))
}
.buttonStyle(.plain)
.overlay(alignment: .bottomTrailing) {
heroThumbnail()
.padding(.trailing, heroTrailingPadding)
.allowsHitTesting(false)
}
// 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 }
}
)
}
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 }
}
.alert(isPresented: $showDismissAlert) {
Alert(
title: Text("Support SimpleX"),
message: Text("You can support SimpleX later in Settings."),
dismissButton: .default(Text("Ok"), action: onDismiss)
)
}
}
@ViewBuilder
private func heroThumbnail() -> some View {
#if SIMPLEX_ASSETS
// .aspectRatio(.fill) draws at NATURAL aspect (0.795) filling the frame image ends up
// ~110×138. .frame(width, heroVisibleHeight, alignment: .top) makes the visible slot
// shorter than the drawn image, top-aligned; .clipped() cuts the ~30pt of natural image
// that would otherwise overflow the slot. The crop line lands ~78% into the phone body.
// draws at natural aspect, top-aligned in a shorter slot; .clipped() cuts the overflow at card bottom
Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light")
.resizable()
.aspectRatio(contentMode: .fill)
@@ -125,11 +99,8 @@ struct SupportSimpleXBanner: View {
@ViewBuilder
private func gradientBackground() -> some View {
// Light: just the first and last stops of OnboardingCardView.lightStops as a simple
// 2-color gradient the intermediate stops there include a hard blue plateau (0.00.5)
// that reads as "almost entirely blue" on this wide-short banner.
// Dark: the full OnboardingCardView.darkStops (all 5 locations) the intermediate blue-to-
// navy transitions land nicely on this shape, giving depth we'd lose with only 2 colors.
// light: 2-color end-to-end (lightStops' 0.00.5 blue plateau would read as all-blue here).
// dark: full 5-stop darkStops the intermediate blue-to-navy transitions land nicely.
if colorScheme == .light {
let stops = OnboardingCardView.lightStops
LinearGradient(
@@ -149,7 +120,7 @@ struct SupportSimpleXBanner: View {
struct SupportSimpleXBanner_Previews: PreviewProvider {
static var previews: some View {
SupportSimpleXBanner(onTap: {})
SupportSimpleXBanner(onTap: {}, onDismiss: {})
.padding()
}
}
@@ -374,19 +374,6 @@ 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 {
@@ -438,8 +425,13 @@ struct ChatListView: View {
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
if !supporterBannerShown && visibleChatCount > 2 {
SupportSimpleXBanner(onTap: { showBadgesSheet = true })
// +1 vs SimpleX Lock's threshold (ContentView) accounts for the always-present
// SimpleX Directory contact card, so this still needs 3 real conversations.
if !supporterBannerShown && chatModel.chats.count > 3 {
SupportSimpleXBanner(
onTap: { showBadgesSheet = true },
onDismiss: { withAnimation { supporterBannerShown = true } }
)
.padding(.vertical, 3)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)