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