crossmate

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

AnnouncementBanner.swift (4782B)


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