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 }