crossmate

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

PuzzleScoreboard.swift (14600B)


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