crossmate

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

commit fea803de1c4dc23a9dff99a9177d4b5e8d686b79
parent 2d1afc0f405b59e6c6f98c26d8bb778a60371e71
Author: Michael Camilleri <[email protected]>
Date:   Fri, 19 Jun 2026 16:01:07 +0900

Fold the nudge button into the scoreboard's Players heading

The puzzle scoreboard carried a separate nudge control beside the
'Players' heading. This commit makes the heading itself the control:
when a peer is present, 'Players' renders as a tinted capsule button
carrying a wave symbol in the local player's colour, and tapping it
cross-fades the heading to a brief 'Nudge Sent' confirmation and back.
The confirmation is laid out over the capsule's exact footprint, so the
score chips below never shift as it swaps.

A solo game — where there is no one to nudge — keeps the plain,
non-button heading at its original prominence.

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

Diffstat:
MCrossmate/Views/Puzzle/PuzzleScoreboard.swift | 107+++++++++++++++++++++++++++++++++++++++++--------------------------------------
1 file changed, 56 insertions(+), 51 deletions(-)

diff --git a/Crossmate/Views/Puzzle/PuzzleScoreboard.swift b/Crossmate/Views/Puzzle/PuzzleScoreboard.swift @@ -10,6 +10,8 @@ struct PuzzleScoreboard: View { var canNudge: () -> Bool = { false } @Environment(PlayerPreferences.self) private var preferences @ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56 + /// Briefly swaps the nudge capsule for a "Nudge Sent" confirmation. + @State private var showNudgeSent = false enum Layout { /// Side-panel style: stacked rows under a "Players" heading. @@ -189,7 +191,7 @@ struct PuzzleScoreboard: View { private var verticalBody: some View { VStack(alignment: .leading, spacing: 12) { - heading(font: .headline) + playersHeading VStack(alignment: .leading, spacing: 6) { ForEach(scores) { score in @@ -225,75 +227,78 @@ struct PuzzleScoreboard: View { // rather than a stray chip, and scrolls when there are enough // players to overflow the band — no centring tricks required. ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 6) { - horizontalHeading + VStack(spacing: 7) { + playersHeading chipFlow } .frame(maxWidth: .infinity) - .padding(.vertical, 8) + .padding(.vertical, 4) } .frame(height: horizontalHeaderHeight) } - private var horizontalHeading: some View { - Text("Players") - .font(.subheadline.weight(.semibold)) - .overlay(alignment: .trailing) { - nudgeButton - .offset(x: 46) - } - } - - private func heading(font: Font) -> some View { - HStack(spacing: 18) { - Text("Players") - .font(font) - - if layout == .vertical { - nudgeButton - } - } - } - - private var nudgeButton: some View { - Group { - if showsNudgeButton { + /// The "Players" heading, shared by both layouts. When there's someone to + /// nudge it *is* the nudge button — a tinted capsule carrying the wave + /// symbol and the title that, on tap, swaps to a brief "Nudge Sent" + /// confirmation. Otherwise it falls back to a plain heading. + @ViewBuilder + private var playersHeading: some 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 { - if let onNudge { - Task { await onNudge() } - } + sendNudge() } label: { - nudgeButtonLabel + playersCapsule } - .buttonStyle(.borderless) - .font(.subheadline.weight(.semibold)) - .disabled(onNudge == nil || !canNudge()) + .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 + // plain, non-button heading, at each layout's original prominence. + // Match the capsule's vertical padding so the heading keeps the same + // height as the button form and the chips/rows below don't shift. + Text("Players") + .font(layout == .vertical ? .headline : .subheadline.weight(.semibold)) + .padding(.top, 5) } } - @ViewBuilder - private var nudgeButtonLabel: some View { - switch layout { - case .vertical: - Image(systemName: "hand.wave") - .foregroundStyle(nudgeIconForeground) - .padding(6) - .nudgeGlass() - case .horizontal: - Image(systemName: "hand.wave") - .foregroundStyle(nudgeIconForeground) - .padding(6) - .nudgeGlass() + /// Fires the nudge and flashes the "Nudge Sent" confirmation in the header + /// for a couple of seconds before restoring the button. + private func sendNudge() { + if let onNudge { + Task { await onNudge() } + } + withAnimation(.easeInOut(duration: 0.25)) { showNudgeSent = true } + Task { + try? await Task.sleep(for: .seconds(3)) + withAnimation(.easeInOut(duration: 0.25)) { showNudgeSent = false } } } - private var nudgeIconForeground: Color { - if #available(iOS 26.0, *) { - return .primary + private var playersCapsule: some View { + HStack(spacing: 5) { + Image(systemName: "hand.wave") + Text("Players") } - return .accentColor + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(preferences.color.tint, in: Capsule()) } private func scoreChip(_ score: Score) -> some View {