crossmate

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

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:
MCrossmate/Models/Puzzle.swift | 2++
MCrossmate/Views/PuzzleView.swift | 177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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) } }