SuccessPanel.swift (16680B)
1 import SwiftUI 2 3 struct SuccessPanel: View { 4 let session: PlayerSession 5 let roster: PlayerRoster 6 /// Drives the finish-banner replay scrubber and the grid override above. 7 var replay: ReplayControls? = nil 8 /// Loads the merged journal (Phase 2b). Called once when the banner appears 9 /// (and again on "check again" while waiting on a peer's upload). 10 var loadReplay: (() async -> JournalReplayResult)? = nil 11 @Environment(PlayerPreferences.self) private var preferences 12 13 private struct Contribution: Identifiable { 14 let authorID: String? 15 let name: String 16 let color: PlayerColor? 17 let count: Int 18 19 var id: String { authorID ?? "unattributed" } 20 } 21 22 private var revealedSquareCount: Int { 23 let replayCells = replay?.frame?.cells 24 var count = 0 25 for r in 0..<session.puzzle.height { 26 for c in 0..<session.puzzle.width { 27 let cell = session.puzzle.cells[r][c] 28 guard !cell.isBlock else { continue } 29 if displayedCellState(row: r, col: c, replayCells: replayCells).mark.isRevealed { 30 count += 1 31 } 32 } 33 } 34 return count 35 } 36 37 private var revealedSquaresText: String { 38 switch revealedSquareCount { 39 case 0: 40 return "No squares revealed" 41 case 1: 42 return "1 square revealed" 43 default: 44 return "\(revealedSquareCount) squares revealed" 45 } 46 } 47 48 private var contributions: [Contribution] { 49 let replayCells = replay?.frame?.cells 50 var counts: [String?: Int] = [:] 51 for r in 0..<session.puzzle.height { 52 for c in 0..<session.puzzle.width { 53 let cell = session.puzzle.cells[r][c] 54 guard !cell.isBlock else { continue } 55 let state = displayedCellState(row: r, col: c, replayCells: replayCells) 56 guard !state.mark.isRevealed else { continue } 57 let entry = state.letter 58 guard !entry.isEmpty else { continue } 59 if cell.solution != nil, !cell.accepts(entry) { continue } 60 counts[normalizedAuthorID(state.cellAuthorID), default: 0] += 1 61 } 62 } 63 64 let entries = roster.entries 65 let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) 66 let hasRemotePlayers = entries.contains { !$0.isLocal } 67 let usesLocalFallback = entries.isEmpty 68 let rosterContributions: [Contribution] 69 if usesLocalFallback { 70 rosterContributions = [ 71 Contribution( 72 authorID: nil, 73 name: preferences.name, 74 color: preferences.color, 75 count: counts[nil] ?? 0 76 ) 77 ] 78 } else { 79 rosterContributions = entries.map { entry in 80 Contribution( 81 authorID: entry.authorID, 82 name: entry.name, 83 color: entry.color, 84 count: counts[entry.authorID] ?? 0 85 ) 86 } 87 } 88 let rosterAuthorIDs = Set(entries.map(\.authorID)) 89 90 let countedContributions = counts.compactMap { authorID, count -> Contribution? in 91 if let authorID, rosterAuthorIDs.contains(authorID) { 92 return nil 93 } 94 if authorID == nil && usesLocalFallback { 95 return nil 96 } 97 if let authorID, let entry = entryByAuthorID[authorID] { 98 return Contribution(authorID: authorID, name: entry.name, color: entry.color, count: count) 99 } 100 if authorID == nil && !hasRemotePlayers { 101 return Contribution(authorID: nil, name: preferences.name, color: preferences.color, count: count) 102 } 103 if authorID == nil { 104 // A `nil` author key only arises with remote players present 105 // (see `normalizedAuthorID`): an authorless square, e.g. a cell 106 // sealed to the solution at completion before its author's 107 // letter arrived. It belongs to no player, so drop it rather 108 // than surfacing a phantom "Player". 109 return nil 110 } 111 return Contribution(authorID: authorID, name: "Player", color: nil, count: count) 112 } 113 114 return (rosterContributions + countedContributions) 115 .sorted { 116 if $0.count != $1.count { return $0.count > $1.count } 117 return $0.name < $1.name 118 } 119 } 120 121 private func displayedCellState( 122 row: Int, 123 col: Int, 124 replayCells: [GridPosition: JournalCellState]? 125 ) -> JournalCellState { 126 let position = GridPosition(row: row, col: col) 127 if let replayCells { 128 return replayCells[position] ?? .empty 129 } 130 131 let square = session.game.squares[row][col] 132 return JournalCellState( 133 letter: square.entry, 134 mark: square.mark, 135 cellAuthorID: square.letterAuthorID 136 ) 137 } 138 139 private func normalizedAuthorID(_ authorID: String?) -> String? { 140 guard let authorID else { 141 return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID 142 } 143 return authorID 144 } 145 146 var body: some View { 147 VStack(spacing: 0) { 148 if let replay { 149 ReplayScrubber(replay: replay, fillColor: preferences.color.tint) 150 } 151 scoreboard 152 } 153 .frame(maxWidth: .infinity, maxHeight: .infinity) 154 // Drive the load from the panel itself, not the scrubber subview: the 155 // scrubber renders `EmptyView` until a timeline loads, and SwiftUI skips 156 // a non-rendering view's `.task`. Keyed on `reloadToken` so "check 157 // again" re-fires it. 158 .task(id: replay?.reloadToken) { 159 guard let replay, let loadReplay else { return } 160 await replay.load(loadReplay) 161 } 162 // A contributor's journal just synced — re-check completeness so a 163 // waiting scrubber flips to ready without polling. Same async-sequence 164 // idiom PlayerRoster uses for `.playerRosterShouldRefresh`. 165 .task { 166 let gameID = session.mutator.gameID 167 for await note in NotificationCenter.default.notifications(named: .replayJournalDidSync) { 168 guard let replay, case .waiting = replay.status, 169 let gameIDs = note.userInfo?["gameIDs"] as? Set<UUID>, 170 gameIDs.contains(gameID) 171 else { continue } 172 replay.retry() 173 } 174 } 175 } 176 177 private var scoreboard: some View { 178 HStack(alignment: .center, spacing: 16) { 179 VStack(alignment: .center, spacing: 8) { 180 Image(systemName: "checkmark.seal.fill") 181 .font(.system(size: 44)) 182 .foregroundStyle(.tint) 183 184 VStack(alignment: .center, spacing: 2) { 185 Text(session.puzzle.title) 186 .font(.subheadline.weight(.semibold)) 187 .lineLimit(1) 188 if let date = session.puzzle.date { 189 Text(date, format: .dateTime.day().month(.abbreviated).year()) 190 .font(.caption) 191 .foregroundStyle(.secondary) 192 .lineLimit(1) 193 } 194 if let author = session.puzzle.author { 195 Text(author) 196 .font(.caption) 197 .foregroundStyle(.secondary) 198 .lineLimit(1) 199 } 200 if let publisher = session.puzzle.publisher { 201 Text(publisher) 202 .font(.caption) 203 .foregroundStyle(.secondary) 204 .lineLimit(1) 205 } 206 } 207 .multilineTextAlignment(.center) 208 .frame(maxWidth: .infinity, alignment: .center) 209 } 210 .frame(maxWidth: .infinity, alignment: .center) 211 212 VStack(alignment: .leading, spacing: 12) { 213 Text("Players") 214 .font(.headline) 215 216 ScrollView { 217 VStack(alignment: .leading, spacing: 6) { 218 ForEach(contributions) { contribution in 219 HStack(spacing: 8) { 220 Circle() 221 .fill(contribution.color?.tint ?? Color.secondary) 222 .frame(width: 8, height: 8) 223 Text(contribution.name) 224 .font(.subheadline) 225 .lineLimit(1) 226 Spacer(minLength: 8) 227 Text("\(contribution.count)") 228 .font(.subheadline.monospacedDigit().weight(.semibold)) 229 } 230 } 231 232 Text(revealedSquaresText) 233 .font(.footnote) 234 .foregroundStyle(.secondary) 235 .padding(.top, 16) 236 .frame(maxWidth: .infinity, alignment: .center) 237 } 238 } 239 .scrollIndicators(.hidden) 240 } 241 // Cap the list width so a wide iPad column doesn't strand each 242 // name far from its count, then expand-to-fill and centre that 243 // capped block within the column. 244 .frame(maxWidth: 320, alignment: .leading) 245 .frame(maxWidth: .infinity) 246 .padding(.top, 8) 247 } 248 .padding(.leading, 18) 249 .padding(.trailing, 24) 250 .padding(.top, 8) 251 .padding(.bottom, 4) 252 .frame(maxWidth: .infinity, maxHeight: .infinity) 253 } 254 } 255 256 /// The replay scrubber that sits atop the finish banner. The thumb starts at 257 /// the far right (the finished grid) and dragging left rewinds the puzzle grid 258 /// above through its move history. Disabled with a sync caption while a 259 /// contributing device's journal is still missing (strict completeness). 260 private struct ReplayScrubber: View { 261 @Bindable var replay: ReplayControls 262 /// The local player's accent colour, for the filled track. 263 var fillColor: Color 264 265 /// Shared height for every scrubber state (slider, waiting, loading) so the 266 /// banner doesn't change height as it loads, and captions sit vertically 267 /// centred in the same space the slider occupies. 268 private static let rowHeight: CGFloat = 24 269 270 var body: some View { 271 Group { 272 switch replay.status { 273 case .ready(let timeline) where timeline.count > 0: 274 slider(count: timeline.count) 275 case .waiting: 276 waiting 277 case .loading: 278 caption { 279 ProgressView().controlSize(.small) 280 Text("Loading replay…") 281 } 282 case .idle, .ready, .unavailable: 283 // Not started yet (`.idle`), nothing to replay (empty log), or 284 // no reachable history. Hold the row's height anyway so the 285 // banner doesn't change size as the load resolves — otherwise 286 // the scoreboard below hitches up and down as the scrubber 287 // appears then collapses. 288 Color.clear.frame(height: Self.rowHeight) 289 } 290 } 291 .padding(.horizontal, 18) 292 .padding(.top, 14) 293 } 294 295 private func slider(count: Int) -> some View { 296 HStack(spacing: 10) { 297 historyOrSpeedControl 298 CompactSlider( 299 value: $replay.position, 300 range: 0...count, 301 fillColor: UIColor(fillColor), 302 // A manual scrub always wins: cancel autoplay the moment the 303 // user grabs the thumb, while preserving the selected speed. 304 onUserScrub: { replay.pausePlayback() } 305 ) 306 PlaybackControl( 307 isPlaying: replay.isPlaybackActive, 308 selectedSpeed: replay.selectedPlaybackSpeed, 309 onTap: { replay.togglePlayback() } 310 ) 311 } 312 .frame(height: Self.rowHeight) 313 // Drive autoplay: play/pause and speed changes restart the loop with 314 // the current interval. A paused replay yields a `nil` interval, so the 315 // task exits and the grid rests. 316 .task(id: "\(replay.isPlaybackActive)-\(replay.selectedPlaybackSpeed)") { 317 guard let interval = replay.playbackStepInterval else { return } 318 while !Task.isCancelled { 319 try? await Task.sleep(for: interval) 320 if Task.isCancelled { break } 321 replay.advancePlayback() 322 } 323 } 324 } 325 326 private var historyOrSpeedControl: some View { 327 ZStack { 328 Image(systemName: "clock.arrow.circlepath") 329 .font(.footnote) 330 .foregroundStyle(.secondary) 331 .opacity(replay.isPlaybackActive ? 0 : 1) 332 .scaleEffect(replay.isPlaybackActive ? 0.75 : 1) 333 .blur(radius: replay.isPlaybackActive ? 2 : 0) 334 .accessibilityHidden(replay.isPlaybackActive) 335 336 Button { 337 replay.cycleSelectedPlaybackSpeed() 338 } label: { 339 Text("\(replay.selectedPlaybackSpeed)x") 340 .font(.caption2.monospacedDigit().weight(.semibold)) 341 .contentTransition(.numericText()) 342 .frame(width: 28, height: 18) 343 .glassEffect(.regular.interactive(), in: .capsule) 344 .animation(.easeInOut(duration: 0.16), value: replay.selectedPlaybackSpeed) 345 } 346 .foregroundStyle(.secondary) 347 .buttonStyle(.plain) 348 .opacity(replay.isPlaybackActive ? 1 : 0) 349 .scaleEffect(replay.isPlaybackActive ? 1 : 1.35) 350 .blur(radius: replay.isPlaybackActive ? 0 : 2) 351 .allowsHitTesting(replay.isPlaybackActive) 352 .accessibilityHidden(!replay.isPlaybackActive) 353 .accessibilityLabel("Replay speed") 354 .accessibilityValue("Speed \(replay.selectedPlaybackSpeed) of \(ReplayControls.maxPlaybackSpeed)") 355 } 356 .frame(width: 30, height: 22) 357 // The symbol/speed glyphs read a touch high against the slider track; 358 // nudge the pair down a point to sit centred on it. 359 .offset(y: 1) 360 .animation(.spring(response: 0.34, dampingFraction: 0.62), value: replay.isPlaybackActive) 361 } 362 363 private var waiting: some View { 364 caption { 365 Image(systemName: "arrow.triangle.2.circlepath") 366 .font(.footnote) 367 if case .waiting(let missing) = replay.status { 368 Text("Waiting for \(missing) device\(missing == 1 ? "" : "s") to sync history") 369 .lineLimit(1) 370 } 371 Spacer(minLength: 8) 372 Button("Check again") { replay.retry() } 373 .font(.caption.weight(.semibold)) 374 .buttonStyle(.borderless) 375 } 376 } 377 378 private func caption<Content: View>(@ViewBuilder _ content: () -> Content) -> some View { 379 HStack(spacing: 8, content: content) 380 .font(.caption) 381 .foregroundStyle(.secondary) 382 .frame(height: Self.rowHeight) 383 .frame(maxWidth: .infinity, alignment: .leading) 384 } 385 } 386 387 /// A shaped play/pause control for replay autoplay. 388 private struct PlaybackControl: View { 389 let isPlaying: Bool 390 let selectedSpeed: Int 391 let onTap: () -> Void 392 393 var body: some View { 394 Button(action: onTap) { 395 Image(systemName: isPlaying ? "pause.fill" : "play.fill") 396 .font(.caption2.weight(.heavy)) 397 .frame(width: 26, height: 18) 398 .padding(.horizontal, 7) 399 .padding(.vertical, 4) 400 .glassEffect(.regular.interactive(), in: .capsule) 401 .animation(.easeInOut(duration: 0.18), value: isPlaying) 402 } 403 .buttonStyle(.plain) 404 .accessibilityLabel(isPlaying ? "Pause replay" : "Play replay") 405 .accessibilityValue(isPlaying ? "Playing at speed \(selectedSpeed)" : "Paused") 406 } 407 }