crossmate

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

AnnouncementBanner.swift (3991B)


      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     let onDismiss: (() -> Void)?
     13 
     14     var body: some View {
     15         let severityColor = tint(for: announcement.severity)
     16         HStack(alignment: .center, spacing: 8) {
     17             Image(systemName: iconName(for: announcement.severity))
     18                 .font(.footnote.weight(.semibold))
     19                 .foregroundStyle(severityColor)
     20                 .frame(width: 24)
     21                 .accessibilityHidden(true)
     22 
     23             VStack(alignment: .leading, spacing: 3) {
     24                 if let title = announcement.title, !title.isEmpty {
     25                     Text(title)
     26                         .font(.subheadline.weight(.semibold))
     27                         .lineLimit(1)
     28                 }
     29                 Text(announcement.body)
     30                     .font(.subheadline)
     31                     .foregroundStyle(.primary)
     32                     .multilineTextAlignment(.leading)
     33                     .lineLimit(2)
     34             }
     35             .frame(maxWidth: .infinity, alignment: .leading)
     36 
     37             if case .manual = announcement.dismissal {
     38                 Button {
     39                     onDismiss?()
     40                 } label: {
     41                     Image(systemName: "xmark.circle.fill")
     42                         .font(.callout)
     43                         .symbolRenderingMode(.hierarchical)
     44                 }
     45                 .buttonStyle(.plain)
     46                 .foregroundStyle(.secondary)
     47                 .accessibilityLabel("Dismiss announcement")
     48             }
     49         }
     50         .padding(.horizontal, 10)
     51         .padding(.vertical, 6)
     52         .padding(.leading, 4)
     53         .frame(
     54             maxWidth: .infinity,
     55             maxHeight: fillsAvailableHeight ? .infinity : nil,
     56             alignment: .leading
     57         )
     58         .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
     59         // The severity rail is drawn as an overlay rather than laid out as an
     60         // HStack child: a width-constrained `Rectangle` in the HStack is
     61         // greedy for height, which made the whole banner expand to fill
     62         // whatever its container offered instead of hugging the text.
     63         .overlay(alignment: .leading) {
     64             Rectangle()
     65                 .fill(severityColor)
     66                 .frame(width: 4)
     67                 .accessibilityHidden(true)
     68         }
     69         .overlay {
     70             RoundedRectangle(cornerRadius: 8, style: .continuous)
     71                 .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
     72         }
     73         .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
     74         .contentShape(Rectangle())
     75         .onTapGesture {
     76             // `.transient` self-dismisses; `.sticky` requires programmatic
     77             // dismissal. Only `.manual` reacts to a tap.
     78             guard case .manual = announcement.dismissal else { return }
     79             onDismiss?()
     80         }
     81         .accessibilityElement(children: .combine)
     82     }
     83 
     84     /// SF Symbol that leads the banner text, one per severity.
     85     private func iconName(for severity: Announcement.Severity) -> String {
     86         switch severity {
     87         case .info: return "bell"
     88         case .warning: return "exclamationmark.triangle"
     89         case .error: return "xmark.octagon"
     90         }
     91     }
     92 
     93     private func tint(for severity: Announcement.Severity) -> Color {
     94         switch severity {
     95         case .info: return .blue
     96         case .warning: return .orange
     97         case .error: return .red
     98         }
     99     }
    100 }