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:
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(