commit c1c1bdca5cf80da53f9099e7294235d85eaf3578
parent e4decd765794323240461d37fe59d096c27b59a9
Author: Michael Camilleri <[email protected]>
Date: Tue, 19 May 2026 05:25:13 +0900
Add swipeable title/scoreboard/credits to Puzzle Grid
The static title block above the grid now pages horizontally. Page one is
the existing title and publisher · date subtitle; the last page is a
credits page; on iPhone a scoreboard page sits between them. iPad omits
the scoreboard page since both iPad layouts already show PuzzleScoreboard
permanently in the side panel — showsScoreboard is gated on padLayout ==
nil. The credits page is dropped entirely when a puzzle has neither author
nor a derivable copyright, so the pager never shows an empty page.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
2 files changed, 177 insertions(+), 2 deletions(-)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -20,6 +20,7 @@ struct Puzzle: Sendable {
let title: String
let publisher: String?
let author: String?
+ let copyright: String?
let date: Date?
let width: Int
let height: Int
@@ -94,6 +95,7 @@ struct Puzzle: Sendable {
self.title = xd.title ?? "Untitled"
self.publisher = xd.publisher
self.author = xd.author
+ self.copyright = xd.copyright
self.date = xd.date
self.width = xd.width
self.height = xd.height
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -232,7 +232,13 @@ struct PuzzleView: View {
private var puzzleArea: some View {
ZStack {
VStack(spacing: 4) {
- PuzzleTitle(title: titleParts.title, subtitle: titleParts.subtitle)
+ PuzzleHeader(
+ session: session,
+ roster: roster,
+ title: titleParts.title,
+ subtitle: titleParts.subtitle,
+ showsScoreboard: padLayout == nil
+ )
GridView(
session: session,
roster: roster,
@@ -444,8 +450,17 @@ struct PuzzleView: View {
private struct PuzzleScoreboard: View {
@Bindable var session: PlayerSession
let roster: PlayerRoster
+ var layout: Layout = .vertical
@Environment(PlayerPreferences.self) private var preferences
+ enum Layout {
+ /// Side-panel style: stacked rows under a "Players" heading.
+ case vertical
+ /// Paged-header style: a horizontally scrollable strip of player
+ /// chips, sized to scroll past two players when more arrive.
+ case horizontal
+ }
+
private struct Score: Identifiable {
let authorID: String?
let name: String
@@ -594,7 +609,17 @@ private struct PuzzleScoreboard: View {
return authorID
}
+ @ViewBuilder
var body: some View {
+ switch layout {
+ case .vertical:
+ verticalBody
+ case .horizontal:
+ horizontalBody
+ }
+ }
+
+ private var verticalBody: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Players")
.font(.headline)
@@ -616,6 +641,41 @@ private struct PuzzleScoreboard: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
+ private var scoreChips: some View {
+ HStack(spacing: 18) {
+ ForEach(scores) { score in
+ scoreChip(score)
+ }
+ }
+ .padding(.horizontal, 18)
+ }
+
+ private var horizontalBody: some View {
+ // Centre the chips when they fit; fall back to a leading scroll
+ // strip only once there are too many players to fit the width.
+ ViewThatFits(in: .horizontal) {
+ scoreChips
+ ScrollView(.horizontal, showsIndicators: false) {
+ scoreChips
+ }
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private func scoreChip(_ score: Score) -> some View {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(score.color?.tint ?? Color.secondary)
+ .frame(width: 8, height: 8)
+ Text(score.name)
+ .font(.subheadline)
+ .lineLimit(1)
+ Text("\(score.filledCount)")
+ .font(.subheadline.monospacedDigit().weight(.semibold))
+ }
+ .accessibilityElement(children: .combine)
+ }
+
private func scoreRow(_ score: Score) -> some View {
HStack(spacing: 8) {
Circle()
@@ -974,6 +1034,96 @@ private struct PuzzlePresentationModifier: ViewModifier {
}
}
+/// Swipeable header that sits above the grid. Page 1 is the title, the
+/// last page is the credits, and on iPhone a scoreboard page sits between
+/// them (iPad shows the scoreboard permanently in the side panel, so it is
+/// omitted here). A fixed height is required because `.page` style fills
+/// its container rather than sizing to content.
+private struct PuzzleHeader: View {
+ @Bindable var session: PlayerSession
+ let roster: PlayerRoster
+ let title: String
+ let subtitle: String?
+ let showsScoreboard: Bool
+ @State private var selection: Page = .title
+
+ private enum Page: Hashable {
+ case title
+ case scoreboard
+ case credits
+ }
+
+ /// Reconstructed as "© <year> <publisher>" from the puzzle's date and
+ /// publisher, falling back to whatever pieces exist, and finally to the
+ /// raw copyright string parsed from the source.
+ private var copyrightLine: String? {
+ let year = session.puzzle.date.map {
+ Calendar.current.component(.year, from: $0)
+ }
+ switch (year, session.puzzle.publisher) {
+ case let (year?, publisher?):
+ return "© \(year) \(publisher)"
+ case let (year?, nil):
+ return "© \(year)"
+ case let (nil, publisher?):
+ return "© \(publisher)"
+ case (nil, nil):
+ return session.puzzle.copyright
+ }
+ }
+
+ private var hasCredits: Bool {
+ session.puzzle.author != nil || copyrightLine != nil
+ }
+
+ private var pages: [Page] {
+ var result: [Page] = [.title]
+ if showsScoreboard { result.append(.scoreboard) }
+ if hasCredits { result.append(.credits) }
+ return result
+ }
+
+ var body: some View {
+ VStack(spacing: 10) {
+ TabView(selection: $selection) {
+ ForEach(pages, id: \.self) { page in
+ pageContent(page)
+ .tag(page)
+ }
+ }
+ .tabViewStyle(.page(indexDisplayMode: .never))
+
+ if pages.count > 1 {
+ HStack(spacing: 6) {
+ ForEach(pages, id: \.self) { page in
+ Circle()
+ .fill(page == selection ? Color.secondary : Color.secondary.opacity(0.3))
+ .frame(width: 6, height: 6)
+ }
+ }
+ .animation(.easeInOut(duration: 0.2), value: selection)
+ }
+ }
+ .frame(height: 80)
+ .padding(.bottom, 14)
+ }
+
+ @ViewBuilder
+ private func pageContent(_ page: Page) -> some View {
+ switch page {
+ case .title:
+ PuzzleTitle(title: title, subtitle: subtitle)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
+ case .scoreboard:
+ PuzzleScoreboard(session: session, roster: roster, layout: .horizontal)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+ case .credits:
+ PuzzleCredits(author: session.puzzle.author, copyright: copyrightLine)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
+ }
+ }
+}
+
private struct PuzzleTitle: View {
let title: String
let subtitle: String?
@@ -993,7 +1143,30 @@ private struct PuzzleTitle: View {
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal)
- .padding(.bottom, 12)
+ }
+}
+
+private struct PuzzleCredits: View {
+ let author: String?
+ let copyright: String?
+
+ var body: some View {
+ VStack(spacing: 2) {
+ if let author {
+ Text(author)
+ .font(.subheadline)
+ .lineLimit(2)
+ }
+ if let copyright {
+ Text(copyright)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal)
}
}