PuzzleScoreboard.swift (17245B)
1 import SwiftUI 2 3 struct PuzzleScoreboard: View { 4 @Bindable var session: PlayerSession 5 let roster: PlayerRoster 6 var layout: Layout = .vertical 7 /// Sends a broadcast nudge to the other players. `nil` hides the button. 8 var onNudge: (() async -> Void)? = nil 9 /// When the next nudge becomes allowed (the send cooldown's end), or `nil` 10 /// if a nudge is allowed right now. 11 var nudgeReadyAt: () -> Date? = { nil } 12 @Environment(PlayerPreferences.self) private var preferences 13 /// Briefly swaps the nudge capsule for a "Nudge Sent" confirmation. 14 @State private var showNudgeSent = false 15 /// The send-cooldown deadline that dims the button, stamped synchronously on 16 /// tap (`tapTime + nudgeCooldown`) so it exists at render time — the 17 /// coordinator stamps its own copy asynchronously, too late for this view. 18 /// `cooldownWatch` clears it at the deadline to un-dim; seeded from the 19 /// coordinator on appear so the dimming survives a view rebuild mid-cooldown. 20 @State private var nudgeDeadline: Date? 21 22 enum Layout { 23 /// Side-panel style: stacked rows under a "Players" heading. 24 case vertical 25 /// Paged-header style: a horizontally scrollable strip of player 26 /// chips, sized to scroll past two players when more arrive. 27 case horizontal 28 } 29 30 private struct Score: Identifiable { 31 let authorID: String? 32 let name: String 33 let color: PlayerColor? 34 let filledCount: Int 35 36 var id: String { authorID ?? "unattributed" } 37 } 38 39 private var fillableCellCount: Int { 40 session.puzzle.cells.reduce(0) { count, row in 41 count + row.filter { !$0.isBlock }.count 42 } 43 } 44 45 private var filledCellCount: Int { 46 var count = 0 47 for r in 0..<session.puzzle.height { 48 for c in 0..<session.puzzle.width { 49 guard !session.puzzle.cells[r][c].isBlock else { continue } 50 if !session.game.squares[r][c].entry.isEmpty { 51 count += 1 52 } 53 } 54 } 55 return count 56 } 57 58 private var revealedSquareCount: Int { 59 var count = 0 60 for r in 0..<session.puzzle.height { 61 for c in 0..<session.puzzle.width { 62 guard !session.puzzle.cells[r][c].isBlock else { continue } 63 if session.game.squares[r][c].mark.isRevealed { 64 count += 1 65 } 66 } 67 } 68 return count 69 } 70 71 private var remainingCount: Int { 72 max(0, fillableCellCount - filledCellCount) 73 } 74 75 private var remainingPhrase: String { 76 switch remainingCount { 77 case 0: 78 return "no squares to go" 79 case 1: 80 return "1 square to go" 81 default: 82 return "\(remainingCount) squares to go" 83 } 84 } 85 86 private var revealedPhrase: String { 87 switch revealedSquareCount { 88 case 0: 89 return "No squares revealed" 90 case 1: 91 return "1 square revealed" 92 default: 93 return "\(revealedSquareCount) squares revealed" 94 } 95 } 96 97 private var progressText: String { 98 if revealedSquareCount > 0 { 99 return "\(revealedPhrase), \(remainingPhrase)" 100 } 101 switch remainingCount { 102 case 0: 103 return "No squares to go" 104 case 1: 105 return "1 square to go" 106 default: 107 return "\(remainingCount) squares to go" 108 } 109 } 110 111 private var scores: [Score] { 112 var counts: [String?: Int] = [:] 113 for r in 0..<session.puzzle.height { 114 for c in 0..<session.puzzle.width { 115 guard !session.puzzle.cells[r][c].isBlock else { continue } 116 let square = session.game.squares[r][c] 117 guard !square.entry.isEmpty, !square.mark.isRevealed else { continue } 118 counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 119 } 120 } 121 122 let entries = roster.entries 123 let usesLocalFallback = entries.isEmpty 124 let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) 125 let rosterAuthorIDs = Set(entries.map(\.authorID)) 126 127 let rosterScores: [Score] 128 if usesLocalFallback { 129 rosterScores = [ 130 Score( 131 authorID: nil, 132 name: preferences.name, 133 color: preferences.color, 134 filledCount: counts[nil] ?? 0 135 ) 136 ] 137 } else { 138 rosterScores = entries.map { entry in 139 Score( 140 authorID: entry.authorID, 141 name: entry.name, 142 color: entry.color, 143 filledCount: counts[entry.authorID] ?? 0 144 ) 145 } 146 } 147 148 let extraScores = counts.compactMap { authorID, count -> Score? in 149 if let authorID, rosterAuthorIDs.contains(authorID) { 150 return nil 151 } 152 if authorID == nil && usesLocalFallback { 153 return nil 154 } 155 if let authorID, let entry = entryByAuthorID[authorID] { 156 return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count) 157 } 158 if authorID == nil { 159 // A `nil` author key only arises with remote players present 160 // (see `normalizedAuthorID`): an authorless square, e.g. a cell 161 // sealed to the solution at completion before its author's 162 // letter arrived. It belongs to no player, so drop it rather 163 // than tallying an "Unattributed" entry. 164 return nil 165 } 166 return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) 167 } 168 169 return ParticipantSummaries.sortedByScore( 170 rosterScores + extraScores, 171 score: \.filledCount, 172 name: \.name, 173 id: \.id 174 ) 175 } 176 177 private func normalizedAuthorID(_ authorID: String?) -> String? { 178 guard let authorID else { 179 return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID 180 } 181 return authorID 182 } 183 184 private var showsNudgeButton: Bool { 185 onNudge != nil 186 && !session.mutator.isCompleted 187 && roster.entries.contains(where: { !$0.isLocal }) 188 } 189 190 var body: some View { 191 Group { 192 switch layout { 193 case .vertical: 194 verticalBody 195 case .horizontal: 196 horizontalBody 197 } 198 } 199 .onAppear { if nudgeDeadline == nil { nudgeDeadline = nudgeReadyAt() } } 200 .task(id: nudgeDeadline) { await cooldownWatch() } 201 } 202 203 /// Sleeps until `nudgeDeadline`, then clears it so the button un-dims on its 204 /// own. Re-runs whenever `nudgeDeadline` changes — a fresh nudge or the view 205 /// re-appearing mid-cooldown — and no-ops when none is pending. Re-checks the 206 /// deadline is unchanged before clearing so a nudge fired during the sleep 207 /// isn't cut short. 208 private func cooldownWatch() async { 209 guard let deadline = nudgeDeadline else { return } 210 let delay = deadline.timeIntervalSinceNow 211 if delay > 0 { 212 try? await Task.sleep(for: .seconds(delay)) 213 } 214 if nudgeDeadline == deadline { nudgeDeadline = nil } 215 } 216 217 private var verticalBody: some View { 218 VStack(alignment: .leading, spacing: 12) { 219 verticalHeading 220 221 VStack(alignment: .leading, spacing: 6) { 222 ForEach(scores) { score in 223 scoreRow(score) 224 } 225 226 Text(progressText) 227 .font(.footnote) 228 .foregroundStyle(.secondary) 229 .padding(.top, 10) 230 .frame(maxWidth: .infinity, alignment: .center) 231 } 232 } 233 .padding(.horizontal, 18) 234 .padding(.vertical, 14) 235 .frame(maxWidth: .infinity, alignment: .leading) 236 } 237 238 private var horizontalBody: some View { 239 // The heading/nudge capsule pins to the leading edge and the score 240 // chips flow after it, wrapping leading-to-trailing onto new lines. 241 // The whole [heading | chips] group hugs its content and centres in 242 // the band, so it grows outward from the middle as players arrive, 243 // while the rows inside stay leading-aligned. A vertical ScrollView 244 // absorbs the overflow when enough players wrap past the slim band, 245 // rather than letting it spill into the toolbar above. Filling the 246 // band's height (rather than pinning a fixed height) keeps the strip 247 // inside whatever space the header yields as Dynamic Type grows. 248 // 249 // `.bottom` scroll anchor rests a short strip (the common 2–3 player 250 // case) against the bottom of the band, matching the title/credits 251 // pages, yet still scrolls up from the bottom once the rows overflow. 252 ScrollView(.vertical, showsIndicators: false) { 253 HStack(alignment: .center, spacing: 18) { 254 playersHeading 255 FlowLayout(alignment: .leading, spacing: 18, lineSpacing: 8) { 256 ForEach(scores) { score in 257 scoreChip(score) 258 } 259 } 260 } 261 .frame(maxWidth: .infinity) 262 .padding(.horizontal, 18) 263 .padding(.vertical, 4) 264 } 265 .frame(maxHeight: .infinity) 266 .defaultScrollAnchor(.bottom) 267 // The scoreboard is the least important text in the band, so cap its 268 // type scaling a few steps below the top: past xLarge it stops growing 269 // rather than forcing the chips to wrap further and the band to keep 270 // eating into the grid. 271 .dynamicTypeSize(...DynamicTypeSize.xLarge) 272 } 273 274 /// The vertical (side-panel) heading. With more horizontal room to spare, 275 /// "Players" stays a plain leading heading and the nudge action lives in a 276 /// separate accent-coloured capsule button, vertically centred on the 277 /// trailing side. The button is omitted entirely when there's no one to nudge. 278 @ViewBuilder 279 private var verticalHeading: some View { 280 HStack(spacing: 8) { 281 Text("Players") 282 .font(.headline) 283 if showsNudgeButton { 284 Spacer(minLength: 8) 285 nudgeButton 286 } 287 } 288 } 289 290 /// The trailing nudge capsule used by `verticalHeading`. Wrapped in a ZStack 291 /// with the "Nudge Sent" confirmation and cross-faded with opacity, so the 292 /// heading reserves the capsule's footprint and nothing shifts on tap. 293 private var nudgeButton: some View { 294 // The capsule and confirmation are cross-faded in a ZStack so the 295 // heading reserves the capsule's footprint and nothing shifts on tap. 296 // The cooldown un-dim is driven by `cooldownWatch` (see `body`). 297 ZStack(alignment: .trailing) { 298 Button { 299 sendNudge() 300 } label: { 301 nudgeCapsule 302 } 303 .buttonStyle(.plain) 304 .disabled(isNudgeDisabled) 305 .accessibilityLabel("Nudge Players") 306 .opacity(showNudgeSent ? 0 : 1) 307 .accessibilityHidden(showNudgeSent) 308 309 Text("Nudge Sent") 310 .font(.footnote.weight(.semibold)) 311 .foregroundStyle(.secondary) 312 .opacity(showNudgeSent ? 1 : 0) 313 .accessibilityHidden(!showNudgeSent) 314 } 315 } 316 317 private var nudgeCapsule: some View { 318 HStack(spacing: 5) { 319 Image(systemName: "hand.wave") 320 Text("Nudge") 321 } 322 .font(.footnote.weight(.semibold)) 323 .padding(.horizontal, 12) 324 .padding(.vertical, 4) 325 .nudgeGlass(isLabeled: true) 326 } 327 328 /// The horizontal (paged-header) heading. With little vertical room, the 329 /// "Players" heading itself *is* the nudge button — a tinted capsule carrying 330 /// the wave symbol and the title that, on tap, swaps to a brief "Nudge Sent" 331 /// confirmation. Otherwise it falls back to a plain heading. 332 @ViewBuilder 333 private var playersHeading: some View { 334 if showsNudgeButton { 335 // Keep both the capsule and the confirmation laid out in a ZStack 336 // and cross-fade with opacity, so the header always reserves the 337 // capsule's exact footprint — the score chips below never shift. The 338 // cooldown un-dim is driven by `cooldownWatch` (see `body`). 339 ZStack { 340 Button { 341 sendNudge() 342 } label: { 343 playersCapsule 344 } 345 .buttonStyle(.plain) 346 .disabled(isNudgeDisabled) 347 .accessibilityLabel("Nudge Players") 348 .opacity(showNudgeSent ? 0 : 1) 349 .accessibilityHidden(showNudgeSent) 350 351 Text("Nudge Sent") 352 .font(.footnote.weight(.semibold)) 353 .foregroundStyle(.primary) 354 .opacity(showNudgeSent ? 1 : 0) 355 .accessibilityHidden(!showNudgeSent) 356 } 357 } else { 358 // No one to nudge (a solo game, or no peers have joined yet): the 359 // plain, non-button heading. It centres against the chip row on its 360 // own, so it needs no padding — the capsule's footprint is taller, 361 // but the chips centre against the tallest element either way. 362 Text("Players") 363 .font(.subheadline.weight(.semibold)) 364 } 365 } 366 367 /// The nudge button is disabled mid-confirmation and during the send 368 /// cooldown (a pending `nudgeDeadline`). 369 private var isNudgeDisabled: Bool { 370 showNudgeSent || nudgeDeadline != nil 371 } 372 373 /// Fires the nudge and flashes the "Nudge Sent" confirmation in the header 374 /// for a couple of seconds before restoring the button. Arms the cooldown 375 /// deadline synchronously (`cooldownWatch` clears it) so the button dims with 376 /// the tap regardless of how the actual send fans out. 377 private func sendNudge() { 378 if let onNudge { 379 Task { await onNudge() } 380 } 381 nudgeDeadline = Date().addingTimeInterval(SessionCoordinator.nudgeCooldown) 382 withAnimation(.easeInOut(duration: 0.25)) { showNudgeSent = true } 383 Task { 384 try? await Task.sleep(for: .seconds(3)) 385 withAnimation(.easeInOut(duration: 0.25)) { showNudgeSent = false } 386 } 387 } 388 389 private var playersCapsule: some View { 390 HStack(spacing: 5) { 391 Image(systemName: "hand.wave") 392 Text("Players") 393 } 394 .font(.footnote.weight(.semibold)) 395 .padding(.horizontal, 12) 396 .padding(.vertical, 4) 397 // Real `glassEffect` casts its own ambient shadow that clips untidily 398 // against the band edge on this flat white header, so the capsule 399 // imitates glass rather than using the system material: a translucent 400 // white fill brightens the capsule off the header and the hairline rim 401 // below supplies the lit glass edge, with no shadow. (The iPad side 402 // panel keeps real glass via `nudgeCapsule`/`nudgeGlass`, where the 403 // shadow has room to sit.) 404 .background(Color.white.opacity(0.6), in: Capsule()) 405 // A rim bright along the top, fading dark along the bottom, reads as a 406 // curved glass edge — defining the button without flattening it into a 407 // plain outline. 408 .overlay { 409 Capsule() 410 .strokeBorder( 411 LinearGradient( 412 colors: [.white.opacity(0.4), .black.opacity(0.14)], 413 startPoint: .top, 414 endPoint: .bottom 415 ), 416 lineWidth: 0.75 417 ) 418 } 419 } 420 421 private func scoreChip(_ score: Score) -> some View { 422 HStack(spacing: 6) { 423 Circle() 424 .fill(score.color?.tint ?? Color.secondary) 425 .frame(width: 8, height: 8) 426 Text(score.name) 427 .font(.subheadline) 428 .lineLimit(1) 429 Text("\(score.filledCount)") 430 .font(.subheadline.monospacedDigit().weight(.semibold)) 431 } 432 .accessibilityElement(children: .combine) 433 } 434 435 private func scoreRow(_ score: Score) -> some View { 436 HStack(spacing: 8) { 437 Circle() 438 .fill(score.color?.tint ?? Color.secondary) 439 .frame(width: 8, height: 8) 440 Text(score.name) 441 .font(.subheadline) 442 .lineLimit(1) 443 Spacer(minLength: 8) 444 Text("\(score.filledCount)") 445 .font(.subheadline.monospacedDigit().weight(.semibold)) 446 } 447 .accessibilityElement(children: .combine) 448 } 449 }