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