crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

AnnouncementBanner.swift (5821B)


      1 import SwiftUI
      2 
      3 /// Banner-style surface for an `Announcement`. Renders a severity rail, icon,
      4 /// and body text over a neutral material background. Manual announcements get
      5 /// a close affordance; transient and sticky announcements do not.
      6 struct AnnouncementBanner: View {
      7     enum ContentSize {
      8         case compact
      9         case prominent
     10 
     11         var childSpacing: CGFloat {
     12             switch self {
     13             case .compact: return 8
     14             case .prominent: return 10
     15             }
     16         }
     17 
     18         var iconFont: Font {
     19             switch self {
     20             case .compact: return .footnote.weight(.semibold)
     21             case .prominent: return .subheadline.weight(.semibold)
     22             }
     23         }
     24 
     25         var titleFont: Font {
     26             switch self {
     27             case .compact: return .subheadline.weight(.semibold)
     28             case .prominent: return .callout.weight(.semibold)
     29             }
     30         }
     31 
     32         var bodyFont: Font {
     33             switch self {
     34             case .compact: return .subheadline
     35             case .prominent: return .callout
     36             }
     37         }
     38     }
     39 
     40     let announcement: Announcement
     41     /// When true the banner stretches to the height its container proposes
     42     /// (PuzzleView's fixed-height header slot); by default it hugs its text,
     43     /// which is what inline placements like the game list need.
     44     var fillsAvailableHeight = false
     45     /// Whether to show the leading severity icon. The Settings tips archive
     46     /// turns it off — the rail alone carries the tip styling there.
     47     var showsIcon = true
     48     /// Visual density for the banner content. Puzzle-header announcements use
     49     /// the compact default; Game List announcements opt into larger text.
     50     var contentSize: ContentSize = .compact
     51     /// Fill behind the banner. Defaults to the inset-grouped section fill so
     52     /// banners read as solid cards on the grouped Game List and in the Settings
     53     /// tips archive; override per surface if a different fill is wanted.
     54     var background: AnyShapeStyle = AnyShapeStyle(Color(.secondarySystemGroupedBackground))
     55     let onDismiss: (() -> Void)?
     56 
     57     var body: some View {
     58         let severityColor = tint(for: announcement.severity)
     59         HStack(alignment: .center, spacing: contentSize.childSpacing) {
     60             if showsIcon {
     61                 Image(systemName: iconName(for: announcement.severity))
     62                     .font(contentSize.iconFont)
     63                     .foregroundStyle(severityColor)
     64                     .frame(width: 24)
     65                     .accessibilityHidden(true)
     66             }
     67 
     68             VStack(alignment: .leading, spacing: 3) {
     69                 if let title = announcement.title, !title.isEmpty {
     70                     Text(title)
     71                         .font(contentSize.titleFont)
     72                         .lineLimit(1)
     73                 }
     74                 Text(announcement.body)
     75                     .font(contentSize.bodyFont)
     76                     .foregroundStyle(.primary)
     77                     .multilineTextAlignment(.leading)
     78                     .lineLimit(3)
     79             }
     80             .frame(maxWidth: .infinity, alignment: .leading)
     81 
     82             if case .manual = announcement.dismissal {
     83                 Button {
     84                     onDismiss?()
     85                 } label: {
     86                     Image(systemName: "xmark.circle.fill")
     87                         .font(.callout)
     88                         .symbolRenderingMode(.hierarchical)
     89                 }
     90                 .buttonStyle(.plain)
     91                 .foregroundStyle(.secondary)
     92                 .accessibilityLabel("Dismiss announcement")
     93             }
     94         }
     95         .padding(.horizontal, 10)
     96         .padding(.vertical, 10)
     97         // Without the icon the text would otherwise crowd the severity rail;
     98         // give it a little extra breathing room on the leading edge.
     99         .padding(.leading, showsIcon ? 4 : 12)
    100         .frame(
    101             maxWidth: .infinity,
    102             maxHeight: fillsAvailableHeight ? .infinity : nil,
    103             alignment: .leading
    104         )
    105         .background(background, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
    106         // The severity rail is drawn as an overlay rather than laid out as an
    107         // HStack child: a width-constrained `Rectangle` in the HStack is
    108         // greedy for height, which made the whole banner expand to fill
    109         // whatever its container offered instead of hugging the text.
    110         .overlay(alignment: .leading) {
    111             Rectangle()
    112                 .fill(severityColor)
    113                 .frame(width: 4)
    114                 .accessibilityHidden(true)
    115         }
    116         .overlay {
    117             RoundedRectangle(cornerRadius: 8, style: .continuous)
    118                 .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
    119         }
    120         .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
    121         .contentShape(Rectangle())
    122         .onTapGesture {
    123             // `.transient` self-dismisses; `.sticky` requires programmatic
    124             // dismissal. Only `.manual` reacts to a tap.
    125             guard case .manual = announcement.dismissal else { return }
    126             onDismiss?()
    127         }
    128         .accessibilityElement(children: .combine)
    129     }
    130 
    131     /// SF Symbol that leads the banner text, one per severity.
    132     private func iconName(for severity: Announcement.Severity) -> String {
    133         switch severity {
    134         case .tip: return "lightbulb"
    135         case .info: return "bell"
    136         case .warning: return "exclamationmark.triangle"
    137         case .error: return "xmark.octagon"
    138         }
    139     }
    140 
    141     private func tint(for severity: Announcement.Severity) -> Color {
    142         switch severity {
    143         case .tip: return .yellow
    144         case .info: return .blue
    145         case .warning: return .orange
    146         case .error: return .red
    147         }
    148     }
    149 }