crossmate

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

commit 464db7cbcda97d045a4a5f1485456b9a60aff2ee
parent a3c998b35f0b2532f99c31f6559be20f63794190
Author: Michael Camilleri <[email protected]>
Date:   Fri, 19 Jun 2026 18:27:29 +0900

Fix nudge button dimming behaviour

After a nudge is sent the nudge button correctly disables for the
60-second cooldown, but it then stays dimmed indefinitely — it only
returned to its enabled appearance once some unrelated change happened
to redraw the scoreboard.

The cause was that canNudge() is a pull-only closure reporting a
time-dependent state: nothing signalled SwiftUI to re-evaluate it when
the cooldown actually elapsed. This commit has the SessionCoordinator
expose nudgeReadyAt(gameID:) — the instant the next nudge becomes
allowed, or nil when one is allowed now — and the button derives its
disabled state from that while wrapping its content in a TimelineView
scheduled for that exact instant. The view re-renders and the button
un-dims on its own. The closure replaces canNudge along the nudge UI
path; the players menu, which rebuilds whenever it opens, derives the
same flag from `nudgeReadyAt() != nil`.

A second, smaller fix accompanies it: the nudge capsule's Liquid Glass
treatment fell back to nothing below iOS 26, leaving the button a bare
label on the iOS 17.5 deployment target. nudgeGlass now outlines the
capsule there, mirroring the Replay control's fallback, so it still
reads as a button.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 2+-
MCrossmate/Services/SessionCoordinator.swift | 9+++++++++
MCrossmate/Views/Components/CustomButtons.swift | 8+++++++-
MCrossmate/Views/Puzzle/PuzzleHeader.swift | 4++--
MCrossmate/Views/Puzzle/PuzzleModifiers.swift | 6+++---
MCrossmate/Views/Puzzle/PuzzleScoreboard.swift | 93+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
MCrossmate/Views/Puzzle/PuzzleView.swift | 16++++++++--------
7 files changed, 88 insertions(+), 50 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -702,7 +702,7 @@ private struct PuzzleDisplayView: View { }, onDelete: { try store.deleteGame(id: gameID) }, onNudge: { await services.sessions.nudge(gameID: gameID) }, - canNudge: { services.sessions.canNudge(gameID: gameID) }, + nudgeReadyAt: { services.sessions.nudgeReadyAt(gameID: gameID) }, loadReplay: { let short = gameID.uuidString.prefix(8) // Finished-game timelines are immutable (edit-lockout), diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -174,6 +174,15 @@ final class SessionCoordinator { return now.timeIntervalSince(last) >= Self.nudgeCooldown } + /// When the next nudge for `gameID` becomes allowed, or `nil` if one is + /// allowed right now. The nudge button reads this so it can re-enable itself + /// exactly when the cooldown lapses, rather than polling `canNudge`. + func nudgeReadyAt(gameID: UUID, asOf now: Date = Date()) -> Date? { + guard let last = lastNudge[gameID] else { return nil } + let ready = last.addingTimeInterval(Self.nudgeCooldown) + return ready > now ? ready : nil + } + /// Sends a manual nudge for `gameID` to every other player who isn't /// currently present in the puzzle, rousing them through an APNs alert. A /// deliberate action from the players menu, so unlike the session pushes it diff --git a/Crossmate/Views/Components/CustomButtons.swift b/Crossmate/Views/Components/CustomButtons.swift @@ -34,7 +34,13 @@ extension View { glassEffect(.regular.interactive(), in: Circle()) } } else { - self + // Pre-glass fallback: outline the shape so the control still reads + // as a button, mirroring `replayGlass()`. + if isLabeled { + overlay { Capsule().strokeBorder(.quaternary, lineWidth: 0.5) } + } else { + overlay { Circle().strokeBorder(.quaternary, lineWidth: 0.5) } + } } } } diff --git a/Crossmate/Views/Puzzle/PuzzleHeader.swift b/Crossmate/Views/Puzzle/PuzzleHeader.swift @@ -15,7 +15,7 @@ struct PuzzleHeader: View { let gameID: UUID let isEngagementLive: Bool var onNudge: (() async -> Void)? = nil - var canNudge: () -> Bool = { false } + var nudgeReadyAt: () -> Date? = { nil } /// The shared open "arm" beat, owned by `PuzzleView` so the banner and the /// grid's "changed while you were away" borders reveal together. Until it /// flips (a moment after open), the title is the only thing on screen; @@ -186,7 +186,7 @@ struct PuzzleHeader: View { roster: roster, layout: .horizontal, onNudge: onNudge, - canNudge: canNudge + nudgeReadyAt: nudgeReadyAt ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) case .credits: diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift @@ -12,7 +12,7 @@ struct PuzzleToolbarModifier: ViewModifier { var onNudge: (() async -> Void)? = nil /// Whether a nudge is allowed right now (cooldown elapsed). Read when the /// menu is built. - var canNudge: () -> Bool = { false } + var nudgeReadyAt: () -> Date? = { nil } @Binding var isRenaming: Bool @Binding var renameDraft: String @Binding var isConfirmingResign: Bool @@ -187,7 +187,7 @@ struct PuzzleToolbarModifier: ViewModifier { /// Broadcast "Nudge Players" action. Shown only when nudging is wired up /// (a shared session) and there is at least one other player to rouse; /// disabled while the puzzle is solved or the per-game cooldown is still - /// running. The Menu rebuilds each time it opens, so `canNudge()` is read + /// running. The Menu rebuilds each time it opens, so `nudgeReadyAt()` is read /// fresh and the disabled state tracks the cooldown without observation. @ViewBuilder private var nudgeSection: some View { @@ -196,7 +196,7 @@ struct PuzzleToolbarModifier: ViewModifier { Button("Nudge Players") { Task { await onNudge() } } - .disabled(isSolved || !canNudge()) + .disabled(isSolved || nudgeReadyAt() != nil) } } } diff --git a/Crossmate/Views/Puzzle/PuzzleScoreboard.swift b/Crossmate/Views/Puzzle/PuzzleScoreboard.swift @@ -6,8 +6,9 @@ struct PuzzleScoreboard: View { var layout: Layout = .vertical /// Sends a broadcast nudge to the other players. `nil` hides the button. var onNudge: (() async -> Void)? = nil - /// Whether a nudge is allowed right now (cooldown elapsed). - var canNudge: () -> Bool = { false } + /// When the next nudge becomes allowed (the send cooldown's end), or `nil` + /// if a nudge is allowed right now. + var nudgeReadyAt: () -> Date? = { nil } @Environment(PlayerPreferences.self) private var preferences @ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56 /// Briefly swaps the nudge capsule for a "Nudge Sent" confirmation. @@ -259,23 +260,28 @@ struct PuzzleScoreboard: View { /// with the "Nudge Sent" confirmation and cross-faded with opacity, so the /// heading reserves the capsule's footprint and nothing shifts on tap. private var nudgeButton: some View { - ZStack(alignment: .trailing) { - Button { - sendNudge() - } label: { - nudgeCapsule + // TimelineView re-renders at `nudgeSchedule`'s deadline so the button + // re-evaluates `isNudgeDisabled` and un-dims the instant the cooldown + // lapses — `nudgeReadyAt()` is a pull-only closure SwiftUI can't observe. + TimelineView(.explicit(nudgeSchedule)) { _ in + ZStack(alignment: .trailing) { + Button { + sendNudge() + } label: { + nudgeCapsule + } + .buttonStyle(.plain) + .disabled(isNudgeDisabled) + .accessibilityLabel("Nudge Players") + .opacity(showNudgeSent ? 0 : 1) + .accessibilityHidden(showNudgeSent) + + Text("Nudge Sent") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .opacity(showNudgeSent ? 1 : 0) + .accessibilityHidden(!showNudgeSent) } - .buttonStyle(.plain) - .disabled(!canNudge() || showNudgeSent) - .accessibilityLabel("Nudge Players") - .opacity(showNudgeSent ? 0 : 1) - .accessibilityHidden(showNudgeSent) - - Text("Nudge Sent") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - .opacity(showNudgeSent ? 1 : 0) - .accessibilityHidden(!showNudgeSent) } } @@ -299,24 +305,28 @@ struct PuzzleScoreboard: View { if showsNudgeButton { // Keep both the capsule and the confirmation laid out in a ZStack // and cross-fade with opacity, so the header always reserves the - // capsule's exact footprint — the score chips below never shift. - ZStack { - Button { - sendNudge() - } label: { - playersCapsule + // capsule's exact footprint — the score chips below never shift. The + // TimelineView re-renders at the cooldown deadline so the button + // un-dims on its own (see `nudgeButton`). + TimelineView(.explicit(nudgeSchedule)) { _ in + ZStack { + Button { + sendNudge() + } label: { + playersCapsule + } + .buttonStyle(.plain) + .disabled(isNudgeDisabled) + .accessibilityLabel("Nudge Players") + .opacity(showNudgeSent ? 0 : 1) + .accessibilityHidden(showNudgeSent) + + Text("Nudge Sent") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + .opacity(showNudgeSent ? 1 : 0) + .accessibilityHidden(!showNudgeSent) } - .buttonStyle(.plain) - .disabled(!canNudge() || showNudgeSent) - .accessibilityLabel("Nudge Players") - .opacity(showNudgeSent ? 0 : 1) - .accessibilityHidden(showNudgeSent) - - Text("Nudge Sent") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.primary) - .opacity(showNudgeSent ? 1 : 0) - .accessibilityHidden(!showNudgeSent) } } else { // No one to nudge (a solo game, or no peers have joined yet): the @@ -329,6 +339,19 @@ struct PuzzleScoreboard: View { } } + /// The nudge button is disabled mid-confirmation and during the send + /// cooldown (a pending `nudgeReadyAt()`). + private var isNudgeDisabled: Bool { + showNudgeSent || nudgeReadyAt() != nil + } + + /// Schedule that re-renders the button exactly when the cooldown lapses. A + /// pending deadline fires a single update; otherwise a distant-future + /// placeholder keeps the schedule non-empty without ever firing. + private var nudgeSchedule: [Date] { + [nudgeReadyAt() ?? .distantFuture] + } + /// Fires the nudge and flashes the "Nudge Sent" confirmation in the header /// for a couple of seconds before restoring the button. private func sendNudge() { diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift @@ -32,10 +32,10 @@ struct PuzzleView: View { /// Sends a broadcast nudge to the other players. `nil` for solo/test /// sessions, which hides the menu button. var onNudge: (() async -> Void)? = nil - /// Whether a nudge is allowed right now (cooldown elapsed). Read when the - /// players menu is built. Defaults to `false` so a session without nudging - /// wired up never offers an enabled button. - var canNudge: () -> Bool = { false } + /// When the next nudge becomes allowed (the send cooldown's end), or `nil` + /// if one is allowed right now. A session without nudging wired up hides the + /// button regardless (see `onNudge`). + var nudgeReadyAt: () -> Date? = { nil } /// Loads the finished game's merged journal for the finish-banner replay /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it. var loadReplay: () async -> JournalReplayResult = { .unavailable } @@ -141,7 +141,7 @@ struct PuzzleView: View { canResign: onResign != nil, canDelete: onDelete != nil, onNudge: onNudge, - canNudge: canNudge, + nudgeReadyAt: nudgeReadyAt, isRenaming: $isRenaming, renameDraft: $renameDraft, isConfirmingResign: $isConfirmingResign, @@ -217,7 +217,7 @@ struct PuzzleView: View { session: session, roster: roster, onNudge: onNudge, - canNudge: canNudge + nudgeReadyAt: nudgeReadyAt ) Divider() @@ -257,7 +257,7 @@ struct PuzzleView: View { session: session, roster: roster, onNudge: onNudge, - canNudge: canNudge + nudgeReadyAt: nudgeReadyAt ) .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) @@ -340,7 +340,7 @@ struct PuzzleView: View { gameID: session.mutator.gameID, isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true, onNudge: onNudge, - canNudge: canNudge, + nudgeReadyAt: nudgeReadyAt, isArmed: isArmed ) GridView(