crossmate

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

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 }