crossmate

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

PuzzleScoreboard.swift (17245B)


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