crossmate

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

PuzzleHeader.swift (11336B)


      1 import SwiftUI
      2 
      3 /// Swipeable header that sits above the grid. Page 1 is the title, the
      4 /// last page is the credits, and on iPhone a scoreboard page sits between
      5 /// them (iPad shows the scoreboard permanently in the side panel, so it is
      6 /// omitted here). A fixed height is required because `.page` style fills
      7 /// its container rather than sizing to content.
      8 struct PuzzleHeader: View {
      9     @Bindable var session: PlayerSession
     10     let roster: PlayerRoster
     11     let title: String
     12     let subtitle: String?
     13     let showsScoreboard: Bool
     14     let shouldAutoRevealScoreboard: Bool
     15     let gameID: UUID
     16     let isEngagementLive: Bool
     17     var onNudge: (() async -> Void)? = nil
     18     var nudgeReadyAt: () -> Date? = { nil }
     19     /// The shared open "arm" beat, owned by `PuzzleView` so the banner and the
     20     /// grid's "changed while you were away" borders reveal together. Until it
     21     /// flips (a moment after open), the title is the only thing on screen;
     22     /// then banner posts — including a session summary that arrived during the
     23     /// hold — animate in.
     24     let isArmed: Bool
     25     @Environment(AnnouncementCenter.self) private var announcements
     26     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     27     @State private var selection: Page = .title
     28     @State private var userSelectedPage = false
     29     @State private var isSelectingPageProgrammatically = false
     30     @State private var didAutoRevealScoreboard = false
     31 
     32     private enum Page: Hashable {
     33         case title
     34         case scoreboard
     35         case clock
     36         case credits
     37     }
     38 
     39     /// Reconstructed as "© <year> <publisher>" from the puzzle's date and
     40     /// publisher, falling back to whatever pieces exist, and finally to the
     41     /// raw copyright string parsed from the source.
     42     private var copyrightLine: String? {
     43         let year = session.puzzle.date.map {
     44             Calendar.current.component(.year, from: $0)
     45         }
     46         switch (year, session.puzzle.publisher) {
     47         case let (year?, publisher?):
     48             return "© \(year) \(publisher)"
     49         case let (year?, nil):
     50             return "© \(year)"
     51         case let (nil, publisher?):
     52             return "© \(publisher)"
     53         case (nil, nil):
     54             return session.puzzle.copyright
     55         }
     56     }
     57 
     58     private var hasCredits: Bool {
     59         session.puzzle.author != nil || copyrightLine != nil
     60     }
     61 
     62     private var pages: [Page] {
     63         var result: [Page] = [.title]
     64         if showsScoreboard { result.append(.scoreboard) }
     65         result.append(.clock)
     66         if hasCredits { result.append(.credits) }
     67         return result
     68     }
     69 
     70     /// Above the default text size the clue bar below the grid grows to fit
     71     /// the (must-read) clue, squeezing the grid. The title/scoreboard/credits
     72     /// shown here are the least important text on screen, so the header yields
     73     /// its own height as type scales up — shedding a few points per step down
     74     /// to a legible-enough floor — and hands that space back to the grid. The
     75     /// text inside just truncates within the smaller box. At or below the
     76     /// default size the comfortable full height is preserved.
     77     private var headerHeight: CGFloat {
     78         let sizes = DynamicTypeSize.allCases
     79         guard let current = sizes.firstIndex(of: dynamicTypeSize),
     80               let baseline = sizes.firstIndex(of: .large)
     81         else { return 80 }
     82         let stepsAboveDefault = max(0, current - baseline)
     83         return max(48, 80 - CGFloat(stepsAboveDefault) * 6)
     84     }
     85 
     86     var body: some View {
     87         let visibleAnnouncement = isArmed
     88             ? announcements.current(forGame: gameID)
     89             : nil
     90         Group {
     91             // Title/scoreboard/credits is the baseline — it renders
     92             // immediately on open and stays put. After the open beat we
     93             // start reacting to announcements: the banner slides down
     94             // over the title and slides back out on dismissal. Both
     95             // branches occupy the same fixed-height frame so the grid
     96             // below doesn't jump.
     97             if let announcement = visibleAnnouncement {
     98                 AnnouncementBanner(
     99                     announcement: announcement,
    100                     fillsAvailableHeight: true,
    101                     // Over the puzzle grid the frosted material reads better than
    102                     // the solid grouped fill the Game List and archive use.
    103                     background: AnyShapeStyle(.regularMaterial)
    104                 ) {
    105                     announcements.dismiss(id: announcement.id)
    106                 }
    107                 .padding(.horizontal, 12)
    108                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
    109                 .transition(.move(edge: .bottom).combined(with: .opacity))
    110             } else {
    111                 headerPages
    112                     .transition(.move(edge: .bottom).combined(with: .opacity))
    113             }
    114         }
    115         .frame(height: headerHeight)
    116         .padding(.bottom, 14)
    117         .animation(.easeInOut(duration: 0.3), value: visibleAnnouncement)
    118         .animation(.easeInOut(duration: 0.2), value: isEngagementLive)
    119         .onChange(of: selection) { _, _ in
    120             guard !isSelectingPageProgrammatically else { return }
    121             userSelectedPage = true
    122         }
    123         .task(id: autoRevealTaskID) {
    124             guard let autoRevealTaskID, autoRevealTaskID.shouldReveal else {
    125                 return
    126             }
    127             didAutoRevealScoreboard = false
    128             userSelectedPage = false
    129             try? await Task.sleep(for: .seconds(30))
    130             guard !Task.isCancelled,
    131                   showsScoreboard,
    132                   shouldAutoRevealScoreboard,
    133                   !userSelectedPage,
    134                   !didAutoRevealScoreboard
    135             else { return }
    136 
    137             isSelectingPageProgrammatically = true
    138             withAnimation(.easeInOut(duration: 0.35)) {
    139                 selection = .scoreboard
    140             }
    141             didAutoRevealScoreboard = true
    142             await Task.yield()
    143             isSelectingPageProgrammatically = false
    144         }
    145     }
    146 
    147     private struct AutoRevealTaskID: Equatable {
    148         let gameID: UUID
    149         let shouldReveal: Bool
    150     }
    151 
    152     private var autoRevealTaskID: AutoRevealTaskID? {
    153         AutoRevealTaskID(
    154             gameID: gameID,
    155             shouldReveal: showsScoreboard && shouldAutoRevealScoreboard
    156         )
    157     }
    158 
    159     private var headerPages: some View {
    160         VStack(spacing: 10) {
    161             TabView(selection: $selection) {
    162                 ForEach(pages, id: \.self) { page in
    163                     pageContent(page)
    164                         .tag(page)
    165                 }
    166             }
    167             .tabViewStyle(.page(indexDisplayMode: .never))
    168 
    169             if pages.count > 1 {
    170                 HStack(spacing: 6) {
    171                     ForEach(pages, id: \.self) { page in
    172                         Circle()
    173                             .fill(page == selection ? Color.secondary : Color.secondary.opacity(0.3))
    174                             .frame(width: 6, height: 6)
    175                     }
    176                 }
    177                 .animation(.easeInOut(duration: 0.2), value: selection)
    178             }
    179         }
    180     }
    181 
    182     @ViewBuilder
    183     private func pageContent(_ page: Page) -> some View {
    184         switch page {
    185         case .title:
    186             PuzzleTitle(title: title, subtitle: subtitle, isEngagementLive: isEngagementLive)
    187                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
    188         case .scoreboard:
    189             PuzzleScoreboard(
    190                 session: session,
    191                 roster: roster,
    192                 layout: .horizontal,
    193                 onNudge: onNudge,
    194                 nudgeReadyAt: nudgeReadyAt
    195             )
    196                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
    197         case .clock:
    198             PuzzleClock(roster: roster)
    199                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
    200         case .credits:
    201             PuzzleCredits(author: session.puzzle.author, copyright: copyrightLine)
    202                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
    203         }
    204     }
    205 }
    206 
    207 /// The solve-time clock page. Ticks once a second off `TimelineView`, re-reading
    208 /// the union of every player's active intervals so it counts up live and shows a
    209 /// co-solver's already-elapsed time the moment you join. Freezes automatically
    210 /// once the game is solved (the roster bounds the union at `completedAt`).
    211 private struct PuzzleClock: View {
    212     let roster: PlayerRoster
    213 
    214     var body: some View {
    215         TimelineView(.periodic(from: .now, by: 1)) { context in
    216             VStack(spacing: 2) {
    217                 Text(TimeLog.clockString(roster.solveTime(asOf: context.date)))
    218                     .font(.headline)
    219                     .monospacedDigit()
    220                     .contentTransition(.numericText())
    221                 Text("Solving Time")
    222                     .font(.footnote)
    223                     .foregroundStyle(.secondary)
    224             }
    225             .multilineTextAlignment(.center)
    226             .frame(maxWidth: .infinity)
    227             .padding(.horizontal)
    228         }
    229     }
    230 }
    231 
    232 private struct PuzzleTitle: View {
    233     let title: String
    234     let subtitle: String?
    235     let isEngagementLive: Bool
    236     @State private var showsEngagementIcon = false
    237 
    238     var body: some View {
    239         VStack(spacing: 2) {
    240             Text(title)
    241                 .font(.headline)
    242                 .lineLimit(2)
    243                 .overlay(alignment: .trailing) {
    244                     engagementIcon
    245                         .offset(x: 28)
    246                         .opacity(showsEngagementIcon ? 1 : 0)
    247                         .accessibilityLabel("Engagement live")
    248                         .accessibilityHidden(!showsEngagementIcon)
    249                 }
    250             if let subtitle {
    251                 Text(subtitle)
    252                     .font(.subheadline)
    253                     .foregroundStyle(.secondary)
    254                     .lineLimit(1)
    255             }
    256         }
    257         .multilineTextAlignment(.center)
    258         .frame(maxWidth: .infinity)
    259         .padding(.horizontal)
    260         .animation(.easeInOut(duration: 0.2), value: showsEngagementIcon)
    261         .onAppear {
    262             showsEngagementIcon = isEngagementLive
    263         }
    264         .onChange(of: isEngagementLive) { _, isLive in
    265             withAnimation(.easeInOut(duration: 0.2)) {
    266                 showsEngagementIcon = isLive
    267             }
    268         }
    269     }
    270 
    271     private var engagementIcon: some View {
    272         Image(systemName: "bolt.circle")
    273             .font(.headline)
    274             .foregroundStyle(.green)
    275             .symbolRenderingMode(.monochrome)
    276     }
    277 }
    278 
    279 private struct PuzzleCredits: View {
    280     let author: String?
    281     let copyright: String?
    282 
    283     var body: some View {
    284         VStack(spacing: 2) {
    285             if let author, !author.isEmpty {
    286                 Text("By \(author)")
    287                     .font(.subheadline)
    288                     .lineLimit(2)
    289             }
    290             if let copyright {
    291                 Text(copyright)
    292                     .font(.footnote)
    293                     .foregroundStyle(.secondary)
    294                     .lineLimit(2)
    295             }
    296         }
    297         .multilineTextAlignment(.center)
    298         .frame(maxWidth: .infinity)
    299         .padding(.horizontal)
    300     }
    301 }