commit 91eb8fbbb4a81f91a9e216a2063fc533a844362c
parent 8e48f14bf2d6346095d3b9e2b2e78d1249d189a2
Author: Michael Camilleri <[email protected]>
Date: Thu, 18 Jun 2026 06:44:45 +0900
Expose nudges from the scoreboard
This commit makes the scoreboard the visible home for nudges, placing an
icon-only 'Nudge Players' control beside the 'Players' heading without
using extra player-row space. Shared games pass the existing nudge
action through both iPad scoreboard layouts and the compact iPhone
header scoreboard, so the control appears when there are other players
to nudge.
The puzzle header now reveals the scoreboard once after 30 seconds in an
eligible shared game, unless the user has already moved the header
pages. The nudge control adopts a glass treatment on platforms that
support it, and keeps cooldown availability behind the existing
canNudge gate.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 154 insertions(+), 7 deletions(-)
diff --git a/Crossmate/Views/Components/CustomButtons.swift b/Crossmate/Views/Components/CustomButtons.swift
@@ -24,4 +24,17 @@ extension View {
}
}
}
+
+ @ViewBuilder
+ func nudgeGlass(isLabeled: Bool = false) -> some View {
+ if #available(iOS 26.0, *) {
+ if isLabeled {
+ glassEffect(.regular.interactive(), in: Capsule())
+ } else {
+ glassEffect(.regular.interactive(), in: Circle())
+ }
+ } else {
+ self
+ }
+ }
}
diff --git a/Crossmate/Views/Puzzle/PuzzleHeader.swift b/Crossmate/Views/Puzzle/PuzzleHeader.swift
@@ -11,8 +11,11 @@ struct PuzzleHeader: View {
let title: String
let subtitle: String?
let showsScoreboard: Bool
+ let shouldAutoRevealScoreboard: Bool
let gameID: UUID
let isEngagementLive: Bool
+ var onNudge: (() async -> Void)? = nil
+ var canNudge: () -> Bool = { false }
/// 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;
@@ -22,6 +25,9 @@ struct PuzzleHeader: View {
@Environment(AnnouncementCenter.self) private var announcements
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var selection: Page = .title
+ @State private var userSelectedPage = false
+ @State private var isSelectingPageProgrammatically = false
+ @State private var didAutoRevealScoreboard = false
private enum Page: Hashable {
case title
@@ -105,6 +111,44 @@ struct PuzzleHeader: View {
.padding(.bottom, 14)
.animation(.easeInOut(duration: 0.3), value: visibleAnnouncement)
.animation(.easeInOut(duration: 0.2), value: isEngagementLive)
+ .onChange(of: selection) { _, _ in
+ guard !isSelectingPageProgrammatically else { return }
+ userSelectedPage = true
+ }
+ .task(id: autoRevealTaskID) {
+ guard let autoRevealTaskID, autoRevealTaskID.shouldReveal else {
+ return
+ }
+ didAutoRevealScoreboard = false
+ userSelectedPage = false
+ try? await Task.sleep(for: .seconds(30))
+ guard !Task.isCancelled,
+ showsScoreboard,
+ shouldAutoRevealScoreboard,
+ !userSelectedPage,
+ !didAutoRevealScoreboard
+ else { return }
+
+ isSelectingPageProgrammatically = true
+ withAnimation(.easeInOut(duration: 0.35)) {
+ selection = .scoreboard
+ }
+ didAutoRevealScoreboard = true
+ await Task.yield()
+ isSelectingPageProgrammatically = false
+ }
+ }
+
+ private struct AutoRevealTaskID: Equatable {
+ let gameID: UUID
+ let shouldReveal: Bool
+ }
+
+ private var autoRevealTaskID: AutoRevealTaskID? {
+ AutoRevealTaskID(
+ gameID: gameID,
+ shouldReveal: showsScoreboard && shouldAutoRevealScoreboard
+ )
}
private var headerPages: some View {
@@ -137,7 +181,13 @@ struct PuzzleHeader: View {
PuzzleTitle(title: title, subtitle: subtitle, isEngagementLive: isEngagementLive)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
case .scoreboard:
- PuzzleScoreboard(session: session, roster: roster, layout: .horizontal)
+ PuzzleScoreboard(
+ session: session,
+ roster: roster,
+ layout: .horizontal,
+ onNudge: onNudge,
+ canNudge: canNudge
+ )
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
case .credits:
PuzzleCredits(author: session.puzzle.author, copyright: copyrightLine)
diff --git a/Crossmate/Views/Puzzle/PuzzleScoreboard.swift b/Crossmate/Views/Puzzle/PuzzleScoreboard.swift
@@ -4,6 +4,10 @@ struct PuzzleScoreboard: View {
@Bindable var session: PlayerSession
let roster: PlayerRoster
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 }
@Environment(PlayerPreferences.self) private var preferences
@ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56
@@ -168,6 +172,10 @@ struct PuzzleScoreboard: View {
return authorID
}
+ private var showsNudgeButton: Bool {
+ onNudge != nil && roster.entries.contains(where: { !$0.isLocal })
+ }
+
@ViewBuilder
var body: some View {
switch layout {
@@ -180,8 +188,7 @@ struct PuzzleScoreboard: View {
private var verticalBody: some View {
VStack(alignment: .leading, spacing: 12) {
- Text("Players")
- .font(.headline)
+ heading(font: .headline)
VStack(alignment: .leading, spacing: 6) {
ForEach(scores) { score in
@@ -218,8 +225,7 @@ struct PuzzleScoreboard: View {
// players to overflow the band — no centring tricks required.
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 6) {
- Text("Players")
- .font(.subheadline.weight(.semibold))
+ horizontalHeading
chipFlow
}
.frame(maxWidth: .infinity)
@@ -228,6 +234,67 @@ struct PuzzleScoreboard: View {
.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: 8) {
+ Text("Players")
+ .font(font)
+
+ if layout == .vertical {
+ nudgeButton
+ }
+ }
+ }
+
+ private var nudgeButton: some View {
+ Group {
+ if showsNudgeButton {
+ Button {
+ if let onNudge {
+ Task { await onNudge() }
+ }
+ } label: {
+ nudgeButtonLabel
+ }
+ .buttonStyle(.borderless)
+ .font(.subheadline.weight(.semibold))
+ .disabled(onNudge == nil || !canNudge())
+ .accessibilityLabel("Nudge Players")
+ }
+ }
+ }
+
+ @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()
+ }
+ }
+
+ private var nudgeIconForeground: Color {
+ if #available(iOS 26.0, *) {
+ return .primary
+ }
+ return .accentColor
+ }
+
private func scoreChip(_ score: Score) -> some View {
HStack(spacing: 6) {
Circle()
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -111,6 +111,10 @@ struct PuzzleView: View {
announcements.isInputBlocked(forGame: session.mutator.gameID)
}
+ private var shouldAutoRevealScoreboard: Bool {
+ onNudge != nil && !isSolved && roster.entries.contains(where: { !$0.isLocal })
+ }
+
var body: some View {
Group {
switch padLayout {
@@ -209,7 +213,12 @@ struct PuzzleView: View {
HStack(spacing: 0) {
VStack(spacing: 0) {
if !isSolved {
- PuzzleScoreboard(session: session, roster: roster)
+ PuzzleScoreboard(
+ session: session,
+ roster: roster,
+ onNudge: onNudge,
+ canNudge: canNudge
+ )
Divider()
}
@@ -244,7 +253,12 @@ struct PuzzleView: View {
HStack(alignment: .top, spacing: 0) {
if !isSolved {
- PuzzleScoreboard(session: session, roster: roster)
+ PuzzleScoreboard(
+ session: session,
+ roster: roster,
+ onNudge: onNudge,
+ canNudge: canNudge
+ )
.frame(minWidth: 240, idealWidth: 280, maxWidth: 320)
Divider()
@@ -322,8 +336,11 @@ struct PuzzleView: View {
title: titleParts.title,
subtitle: titleParts.subtitle,
showsScoreboard: padLayout == nil,
+ shouldAutoRevealScoreboard: shouldAutoRevealScoreboard,
gameID: session.mutator.gameID,
isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true,
+ onNudge: onNudge,
+ canNudge: canNudge,
isArmed: isArmed
)
GridView(