PuzzleScoreboard.swift (14600B)
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 typealias Score = PuzzleGridStats.ParticipantCount 31 32 private var gridStats: PuzzleGridStats { 33 let hasRemotePlayers = roster.entries.contains { !$0.isLocal } 34 return PuzzleGridStats( 35 puzzle: session.puzzle, 36 hasRemotePlayers: hasRemotePlayers, 37 localAuthorID: roster.localAuthorID 38 ) { r, c in 39 let square = session.game.squares[r][c] 40 return PuzzleGridStats.CellState( 41 entry: square.entry, 42 mark: square.mark, 43 authorID: square.letterAuthorID 44 ) 45 } 46 } 47 48 private func remainingCount(for stats: PuzzleGridStats) -> Int { 49 max(0, stats.fillableCellCount - stats.filledCellCount) 50 } 51 52 private func remainingPhrase(for stats: PuzzleGridStats) -> String { 53 switch remainingCount(for: stats) { 54 case 0: 55 return "no squares to go" 56 case 1: 57 return "1 square to go" 58 default: 59 return "\(remainingCount(for: stats)) squares to go" 60 } 61 } 62 63 private func revealedPhrase(for stats: PuzzleGridStats) -> String { 64 switch stats.revealedSquareCount { 65 case 0: 66 return "No squares revealed" 67 case 1: 68 return "1 square revealed" 69 default: 70 return "\(stats.revealedSquareCount) squares revealed" 71 } 72 } 73 74 private func progressText(for stats: PuzzleGridStats) -> String { 75 if stats.revealedSquareCount > 0 { 76 return "\(revealedPhrase(for: stats)), \(remainingPhrase(for: stats))" 77 } 78 switch remainingCount(for: stats) { 79 case 0: 80 return "No squares to go" 81 case 1: 82 return "1 square to go" 83 default: 84 return "\(remainingCount(for: stats)) squares to go" 85 } 86 } 87 88 private func scores(for stats: PuzzleGridStats) -> [Score] { 89 return ParticipantSummaries.sortedByScore( 90 stats.participantCounts( 91 entries: roster.entries, 92 localName: preferences.name, 93 localColor: preferences.color 94 ), 95 score: \.count, 96 name: \.name, 97 id: \.id 98 ) 99 } 100 101 private var showsNudgeButton: Bool { 102 onNudge != nil 103 && !session.mutator.isCompleted 104 && roster.entries.contains(where: { !$0.isLocal }) 105 } 106 107 var body: some View { 108 Group { 109 switch layout { 110 case .vertical: 111 verticalBody 112 case .horizontal: 113 horizontalBody 114 } 115 } 116 .onAppear { if nudgeDeadline == nil { nudgeDeadline = nudgeReadyAt() } } 117 .task(id: nudgeDeadline) { await cooldownWatch() } 118 } 119 120 /// Sleeps until `nudgeDeadline`, then clears it so the button un-dims on its 121 /// own. Re-runs whenever `nudgeDeadline` changes — a fresh nudge or the view 122 /// re-appearing mid-cooldown — and no-ops when none is pending. Re-checks the 123 /// deadline is unchanged before clearing so a nudge fired during the sleep 124 /// isn't cut short. 125 private func cooldownWatch() async { 126 guard let deadline = nudgeDeadline else { return } 127 let delay = deadline.timeIntervalSinceNow 128 if delay > 0 { 129 try? await Task.sleep(for: .seconds(delay)) 130 } 131 if nudgeDeadline == deadline { nudgeDeadline = nil } 132 } 133 134 private var verticalBody: some View { 135 let stats = gridStats 136 let scores = scores(for: stats) 137 return VStack(alignment: .leading, spacing: 12) { 138 verticalHeading 139 140 VStack(alignment: .leading, spacing: 6) { 141 ForEach(scores) { score in 142 scoreRow(score) 143 } 144 145 Text(progressText(for: stats)) 146 .font(.footnote) 147 .foregroundStyle(.secondary) 148 .padding(.top, 10) 149 .frame(maxWidth: .infinity, alignment: .center) 150 } 151 } 152 .padding(.horizontal, 18) 153 .padding(.vertical, 14) 154 .frame(maxWidth: .infinity, alignment: .leading) 155 } 156 157 private var horizontalBody: some View { 158 let scores = scores(for: gridStats) 159 // The heading/nudge capsule pins to the leading edge and the score 160 // chips flow after it, wrapping leading-to-trailing onto new lines. 161 // The whole [heading | chips] group hugs its content and centres in 162 // the band, so it grows outward from the middle as players arrive, 163 // while the rows inside stay leading-aligned. A vertical ScrollView 164 // absorbs the overflow when enough players wrap past the slim band, 165 // rather than letting it spill into the toolbar above. Filling the 166 // band's height (rather than pinning a fixed height) keeps the strip 167 // inside whatever space the header yields as Dynamic Type grows. 168 // 169 // `.bottom` scroll anchor rests a short strip (the common 2–3 player 170 // case) against the bottom of the band, matching the title/credits 171 // pages, yet still scrolls up from the bottom once the rows overflow. 172 return ScrollView(.vertical, showsIndicators: false) { 173 HStack(alignment: .center, spacing: 18) { 174 playersHeading 175 FlowLayout(alignment: .leading, spacing: 18, lineSpacing: 8) { 176 ForEach(scores) { score in 177 scoreChip(score) 178 } 179 } 180 } 181 .frame(maxWidth: .infinity) 182 .padding(.horizontal, 18) 183 .padding(.vertical, 4) 184 } 185 .frame(maxHeight: .infinity) 186 .defaultScrollAnchor(.bottom) 187 // The scoreboard is the least important text in the band, so cap its 188 // type scaling a few steps below the top: past xLarge it stops growing 189 // rather than forcing the chips to wrap further and the band to keep 190 // eating into the grid. 191 .dynamicTypeSize(...DynamicTypeSize.xLarge) 192 } 193 194 /// The vertical (side-panel) heading. With more horizontal room to spare, 195 /// "Players" stays a plain leading heading and the nudge action lives in a 196 /// separate accent-coloured capsule button, vertically centred on the 197 /// trailing side. The button is omitted entirely when there's no one to nudge. 198 @ViewBuilder 199 private var verticalHeading: some View { 200 HStack(spacing: 8) { 201 Text("Players") 202 .font(.headline) 203 if showsNudgeButton { 204 Spacer(minLength: 8) 205 nudgeButton 206 } 207 } 208 } 209 210 /// The trailing nudge capsule used by `verticalHeading`. Wrapped in a ZStack 211 /// with the "Nudge Sent" confirmation and cross-faded with opacity, so the 212 /// heading reserves the capsule's footprint and nothing shifts on tap. 213 private var nudgeButton: some View { 214 // The capsule and confirmation are cross-faded in a ZStack so the 215 // heading reserves the capsule's footprint and nothing shifts on tap. 216 // The cooldown un-dim is driven by `cooldownWatch` (see `body`). 217 ZStack(alignment: .trailing) { 218 Button { 219 sendNudge() 220 } label: { 221 nudgeCapsule 222 } 223 .buttonStyle(.plain) 224 .disabled(isNudgeDisabled) 225 .accessibilityLabel("Nudge Players") 226 .opacity(showNudgeSent ? 0 : 1) 227 .accessibilityHidden(showNudgeSent) 228 229 Text("Nudge Sent") 230 .font(.footnote.weight(.semibold)) 231 .foregroundStyle(.secondary) 232 .opacity(showNudgeSent ? 1 : 0) 233 .accessibilityHidden(!showNudgeSent) 234 } 235 } 236 237 private var nudgeCapsule: some View { 238 HStack(spacing: 5) { 239 Image(systemName: "hand.wave") 240 Text("Nudge") 241 } 242 .font(.footnote.weight(.semibold)) 243 .padding(.horizontal, 12) 244 .padding(.vertical, 4) 245 .nudgeGlass(isLabeled: true) 246 } 247 248 /// The horizontal (paged-header) heading. With little vertical room, the 249 /// "Players" heading itself *is* the nudge button — a tinted capsule carrying 250 /// the wave symbol and the title that, on tap, swaps to a brief "Nudge Sent" 251 /// confirmation. Otherwise it falls back to a plain heading. 252 @ViewBuilder 253 private var playersHeading: some View { 254 if showsNudgeButton { 255 // Keep both the capsule and the confirmation laid out in a ZStack 256 // and cross-fade with opacity, so the header always reserves the 257 // capsule's exact footprint — the score chips below never shift. The 258 // cooldown un-dim is driven by `cooldownWatch` (see `body`). 259 ZStack { 260 Button { 261 sendNudge() 262 } label: { 263 playersCapsule 264 } 265 .buttonStyle(.plain) 266 .disabled(isNudgeDisabled) 267 .accessibilityLabel("Nudge Players") 268 .opacity(showNudgeSent ? 0 : 1) 269 .accessibilityHidden(showNudgeSent) 270 271 Text("Nudge Sent") 272 .font(.footnote.weight(.semibold)) 273 .foregroundStyle(.primary) 274 .opacity(showNudgeSent ? 1 : 0) 275 .accessibilityHidden(!showNudgeSent) 276 } 277 } else { 278 // No one to nudge (a solo game, or no peers have joined yet): the 279 // plain, non-button heading. It centres against the chip row on its 280 // own, so it needs no padding — the capsule's footprint is taller, 281 // but the chips centre against the tallest element either way. 282 Text("Players") 283 .font(.subheadline.weight(.semibold)) 284 } 285 } 286 287 /// The nudge button is disabled mid-confirmation and during the send 288 /// cooldown (a pending `nudgeDeadline`). 289 private var isNudgeDisabled: Bool { 290 showNudgeSent || nudgeDeadline != nil 291 } 292 293 /// Fires the nudge and flashes the "Nudge Sent" confirmation in the header 294 /// for a couple of seconds before restoring the button. Arms the cooldown 295 /// deadline synchronously (`cooldownWatch` clears it) so the button dims with 296 /// the tap regardless of how the actual send fans out. 297 private func sendNudge() { 298 if let onNudge { 299 Task { await onNudge() } 300 } 301 nudgeDeadline = Date().addingTimeInterval(SessionCoordinator.nudgeCooldown) 302 withAnimation(.easeInOut(duration: 0.25)) { showNudgeSent = true } 303 Task { 304 try? await Task.sleep(for: .seconds(3)) 305 withAnimation(.easeInOut(duration: 0.25)) { showNudgeSent = false } 306 } 307 } 308 309 private var playersCapsule: some View { 310 HStack(spacing: 5) { 311 Image(systemName: "hand.wave") 312 Text("Players") 313 } 314 .font(.footnote.weight(.semibold)) 315 .padding(.horizontal, 12) 316 .padding(.vertical, 4) 317 // Real `glassEffect` casts its own ambient shadow that clips untidily 318 // against the band edge on this flat white header, so the capsule 319 // imitates glass rather than using the system material: a translucent 320 // white fill brightens the capsule off the header and the hairline rim 321 // below supplies the lit glass edge, with no shadow. (The iPad side 322 // panel keeps real glass via `nudgeCapsule`/`nudgeGlass`, where the 323 // shadow has room to sit.) 324 .background(Color.white.opacity(0.6), in: Capsule()) 325 // A rim bright along the top, fading dark along the bottom, reads as a 326 // curved glass edge — defining the button without flattening it into a 327 // plain outline. 328 .overlay { 329 Capsule() 330 .strokeBorder( 331 LinearGradient( 332 colors: [.white.opacity(0.4), .black.opacity(0.14)], 333 startPoint: .top, 334 endPoint: .bottom 335 ), 336 lineWidth: 0.75 337 ) 338 } 339 } 340 341 private func scoreChip(_ score: Score) -> some View { 342 HStack(spacing: 6) { 343 Circle() 344 .fill(score.color?.tint ?? Color.secondary) 345 .frame(width: 8, height: 8) 346 Text(score.name) 347 .font(.subheadline) 348 .lineLimit(1) 349 Text("\(score.count)") 350 .font(.subheadline.monospacedDigit().weight(.semibold)) 351 } 352 .accessibilityElement(children: .combine) 353 } 354 355 private func scoreRow(_ score: Score) -> some View { 356 HStack(spacing: 8) { 357 Circle() 358 .fill(score.color?.tint ?? Color.secondary) 359 .frame(width: 8, height: 8) 360 Text(score.name) 361 .font(.subheadline) 362 .lineLimit(1) 363 Spacer(minLength: 8) 364 Text("\(score.count)") 365 .font(.subheadline.monospacedDigit().weight(.semibold)) 366 } 367 .accessibilityElement(children: .combine) 368 } 369 }