crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }