crossmate

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

commit a3c5f45ceeb5359eded0c43a2f19bb2f1769f785
parent 740784d5cebbb7eb0f04fe414c0d2af9bebba167
Author: Michael Camilleri <[email protected]>
Date:   Mon, 13 Apr 2026 18:51:54 +0900

Add support for external puzzles

This commit adds an initial implementation for loading and playing
external puzzles. In this first example, the puzzles come from the New
York Times.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 4+++-
MCrossmate/Services/NYTAuthService.swift | 10++++++++--
MCrossmate/Services/NYTPuzzleFetcher.swift | 5+++++
ACrossmate/Views/CalendarDayCell.swift | 27+++++++++++++++++++++++++++
MCrossmate/Views/CellView.swift | 26+++++++++++++++-----------
MCrossmate/Views/NYTBrowseView.swift | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCrossmate/Views/PuzzleView.swift | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
8 files changed, 357 insertions(+), 45 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; + C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; }; CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */; }; CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; @@ -105,6 +106,7 @@ B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; }; BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; + C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; }; CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; @@ -218,6 +220,7 @@ isa = PBXGroup; children = ( 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */, + C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */, F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */, 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */, D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */, @@ -376,6 +379,7 @@ buildActionMask = 2147483647; files = ( AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */, + C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -11,6 +11,7 @@ struct CrossmateApp: App { @State private var nytAuth = NYTAuthService() @State private var ubiquityMonitor = UbiquityMonitor() private let persistence: PersistenceController + private let nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } init() { let persistence = PersistenceController() @@ -34,6 +35,7 @@ struct CrossmateApp: App { ) .environment(nytAuth) .environment(ubiquityMonitor) + .environment(\.nytPuzzleFetcher, nytFetcher) } } } @@ -214,7 +216,7 @@ private struct GameDestinationView: View { ) } } - .navigationTitle(session?.puzzle.title ?? "") + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) } } diff --git a/Crossmate/Services/NYTAuthService.swift b/Crossmate/Services/NYTAuthService.swift @@ -9,8 +9,14 @@ final class NYTAuthService { private(set) var isLoading = false var errorMessage: String? - private static let emailKey = "nyt-email" - private static let cookieKey = "nyt-cookie" + nonisolated private static let emailKey = "nyt-email" + nonisolated private static let cookieKey = "nyt-cookie" + + /// Thread-safe read of the stored NYT cookie for use from non-MainActor contexts. + nonisolated static func currentCookie() -> String? { + guard let data = KeychainHelper.load(key: cookieKey) else { return nil } + return String(data: data, encoding: .utf8) + } static let loginURL = URL(string: "https://myaccount.nytimes.com/auth/login?response_type=cookie&client_id=games&redirect_uri=https%3A%2F%2Fwww.nytimes.com%2Fcrosswords")! diff --git a/Crossmate/Services/NYTPuzzleFetcher.swift b/Crossmate/Services/NYTPuzzleFetcher.swift @@ -1,4 +1,9 @@ import Foundation +import SwiftUI + +extension EnvironmentValues { + @Entry var nytPuzzleFetcher: NYTPuzzleFetcher? = nil +} actor NYTPuzzleFetcher { private let cookieProvider: @Sendable () -> String? diff --git a/Crossmate/Views/CalendarDayCell.swift b/Crossmate/Views/CalendarDayCell.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct CalendarDayCell: View { + let dayNumber: Int + let isEnabled: Bool + let isToday: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + Text("\(dayNumber)") + .font(.body) + .fontWeight(isToday ? .bold : .regular) + .frame(maxWidth: .infinity, minHeight: 44) + .foregroundStyle(isEnabled ? Color.primary : Color.secondary.opacity(0.4)) + .background { + if isToday { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 36, height: 36) + } + } + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } +} diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift @@ -19,14 +19,9 @@ struct CellView: View { .stroke(Color.black.opacity(0.55), lineWidth: 1) .padding(1.5) } - if let number = cell.number { - Text("\(number)") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.6) - .padding(.leading, 2) - .padding(.top, 1) + if cell.number != nil { + CornerTriangle(corner: .topLeading) + .fill(Color.orange) } Text(entry) .font(.system(size: 34, weight: .semibold, design: .rounded)) @@ -94,14 +89,23 @@ struct CellView: View { /// as a fraction of the shorter cell dimension. Used as a small marker for /// revealed and checkedWrong cells. private struct CornerTriangle: Shape { + enum Corner { case topLeading, topTrailing } + var corner: Corner = .topTrailing var fraction: CGFloat = 0.3 func path(in rect: CGRect) -> Path { let side = min(rect.width, rect.height) * fraction var path = Path() - path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side)) + switch corner { + case .topTrailing: + path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side)) + case .topLeading: + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX + side, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + side)) + } path.closeSubpath() return path } diff --git a/Crossmate/Views/NYTBrowseView.swift b/Crossmate/Views/NYTBrowseView.swift @@ -4,11 +4,203 @@ struct NYTBrowseView: View { let store: GameStore let onCreated: (UUID) -> Void + @Environment(\.nytPuzzleFetcher) private var fetcher + @State private var displayedMonth: Date = NYTBrowseView.startOfCurrentMonth() + @State private var isLoading = false + @State private var errorMessage: String? + + private static let nytTimeZone = TimeZone(identifier: "America/New_York")! + + private static var nytCalendar: Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = nytTimeZone + return cal + } + + private static let minDate: Date = { + var comps = DateComponents() + comps.year = 2001 + comps.month = 1 + comps.day = 1 + return nytCalendar.date(from: comps)! + }() + + private static func startOfCurrentMonth() -> Date { + let cal = nytCalendar + let comps = cal.dateComponents([.year, .month], from: Date()) + return cal.date(from: comps) ?? Date() + } + + private let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) + private let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"] + var body: some View { - ContentUnavailableView { - Label("NYT Crosswords", systemImage: "calendar") - } description: { - Text("The NYT calendar browser will appear here.") + VStack(spacing: 16) { + monthHeader + weekdayHeader + dayGrid + Spacer() + } + .padding() + .disabled(isLoading) + .overlay { + if isLoading { + ProgressView("Fetching puzzle…") + .padding() + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + } + } + .alert( + "Couldn't Fetch Puzzle", + isPresented: .init( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ), + presenting: errorMessage + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } + } + + private var monthHeader: some View { + HStack { + Button { + shiftMonth(by: -1) + } label: { + Image(systemName: "chevron.left") + .font(.title3) + } + .disabled(!canGoBack) + + Spacer() + + Text(monthTitle) + .font(.headline) + + Spacer() + + Button { + shiftMonth(by: 1) + } label: { + Image(systemName: "chevron.right") + .font(.title3) + } + .disabled(!canGoForward) + } + } + + private var weekdayHeader: some View { + HStack(spacing: 0) { + ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in + Text(symbol) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + + private var dayGrid: some View { + LazyVGrid(columns: columns, spacing: 4) { + ForEach(Array(gridCells.enumerated()), id: \.offset) { _, cell in + if let date = cell { + let cal = Self.nytCalendar + let dayNumber = cal.component(.day, from: date) + CalendarDayCell( + dayNumber: dayNumber, + isEnabled: isEnabled(date), + isToday: cal.isDateInToday(date), + onTap: { fetch(date) } + ) + } else { + Color.clear.frame(minHeight: 44) + } + } + } + } + + // MARK: - Month navigation + + private func shiftMonth(by delta: Int) { + let cal = Self.nytCalendar + if let next = cal.date(byAdding: .month, value: delta, to: displayedMonth) { + displayedMonth = next + } + } + + private var monthTitle: String { + let formatter = DateFormatter() + formatter.calendar = Self.nytCalendar + formatter.timeZone = Self.nytTimeZone + formatter.dateFormat = "MMMM yyyy" + return formatter.string(from: displayedMonth) + } + + private var canGoBack: Bool { + let cal = Self.nytCalendar + let minMonth = cal.dateComponents([.year, .month], from: Self.minDate) + let current = cal.dateComponents([.year, .month], from: displayedMonth) + guard let cy = current.year, let cm = current.month, + let my = minMonth.year, let mm = minMonth.month else { return false } + return (cy, cm) > (my, mm) + } + + private var canGoForward: Bool { + let cal = Self.nytCalendar + let today = cal.dateComponents([.year, .month], from: Date()) + let current = cal.dateComponents([.year, .month], from: displayedMonth) + guard let cy = current.year, let cm = current.month, + let ty = today.year, let tm = today.month else { return false } + return (cy, cm) < (ty, tm) + } + + // MARK: - Grid + + private var gridCells: [Date?] { + var cells: [Date?] = [] + let cal = Self.nytCalendar + let monthComps = cal.dateComponents([.year, .month], from: displayedMonth) + guard let firstOfMonth = cal.date(from: monthComps), + let range = cal.range(of: .day, in: .month, for: firstOfMonth) else { + return cells + } + let firstWeekday = cal.component(.weekday, from: firstOfMonth) + let leadingBlanks = firstWeekday - 1 + for _ in 0..<leadingBlanks { cells.append(nil) } + for day in range { + cells.append(cal.date(byAdding: .day, value: day - 1, to: firstOfMonth)) + } + while cells.count % 7 != 0 { cells.append(nil) } + return cells + } + + private func isEnabled(_ date: Date) -> Bool { + let cal = Self.nytCalendar + let dayStart = cal.startOfDay(for: date) + let minDayStart = cal.startOfDay(for: Self.minDate) + let todayStart = cal.startOfDay(for: Date()) + return dayStart >= minDayStart && dayStart <= todayStart + } + + // MARK: - Fetch + + private func fetch(_ date: Date) { + guard let fetcher else { + errorMessage = "Puzzle fetcher unavailable." + return + } + isLoading = true + Task { @MainActor in + defer { isLoading = false } + do { + let source = try await fetcher.fetchPuzzle(for: date) + let id = try store.createGame(from: source) + onCreated(id) + } catch { + errorMessage = error.localizedDescription + } } } } diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -4,13 +4,55 @@ struct PuzzleView: View { @Bindable var session: PlayerSession @Environment(\.playerColor) private var playerColor + private struct TitleParts { + let title: String + let subtitle: String? + } + + private var titleParts: TitleParts { + let raw = session.puzzle.title + guard let match = raw.firstMatch(of: /(\d{4})-(\d{2})-(\d{2})/), + let year = Int(match.1), + let month = Int(match.2), + let day = Int(match.3) + else { return TitleParts(title: raw, subtitle: nil) } + var comps = DateComponents() + comps.year = year + comps.month = month + comps.day = day + guard let date = Calendar.current.date(from: comps) else { + return TitleParts(title: raw, subtitle: nil) + } + let formatted = date.formatted(date: .complete, time: .omitted) + let prefix = String(raw[..<match.range.lowerBound]) + .trimmingCharacters(in: .whitespaces) + if prefix.isEmpty { + return TitleParts(title: formatted, subtitle: nil) + } + return TitleParts(title: prefix, subtitle: formatted) + } + var body: some View { VStack(spacing: 0) { ZStack { VStack(spacing: 0) { + VStack(spacing: 2) { + Text(titleParts.title) + .font(.headline) + .lineLimit(2) + if let subtitle = titleParts.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 14) GridView(session: session) - .padding(.horizontal) - .padding(.top) Spacer(minLength: 12) } @@ -78,47 +120,70 @@ struct PuzzleView: View { } } +private struct ClueKey: Hashable { + let direction: Puzzle.Direction + let number: Int +} + private struct ClueBar: View { @Bindable var session: PlayerSession + @State private var previousKey: ClueKey? var body: some View { let clue = session.currentClue() - VStack(alignment: .leading, spacing: 0) { - Text(label(for: clue)) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 44) - .padding(.bottom, -4) - - HStack(spacing: 12) { - Button { - session.goToPreviousClue() - } label: { - Image(systemName: "chevron.left") - .font(.title3.weight(.semibold)) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) + let currentKey = clue.map { ClueKey(direction: session.direction, number: $0.number) } + let slideEdge = slideEdge(from: previousKey, to: currentKey) + + HStack(alignment: .clueCenter, spacing: 12) { + Button { + session.goToPreviousClue() + } label: { + Image(systemName: "chevron.left") + .font(.title3.weight(.semibold)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + VStack(alignment: .leading, spacing: 4) { + Text(label(for: clue)) + .font(.caption) + .foregroundStyle(.secondary) Text(clue?.text ?? "—") .font(.headline) .lineLimit(2) .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) + .alignmentGuide(.clueCenter) { d in d[VerticalAlignment.center] } + .id(currentKey) + .transition(.asymmetric( + insertion: .move(edge: slideEdge), + removal: .move(edge: slideEdge == .trailing ? .leading : .trailing) + )) + } + .frame(maxWidth: .infinity, alignment: .leading) + .clipped() - Button { - session.goToNextClue() - } label: { - Image(systemName: "chevron.right") - .font(.title3.weight(.semibold)) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) + Button { + session.goToNextClue() + } label: { + Image(systemName: "chevron.right") + .font(.title3.weight(.semibold)) + .frame(width: 32, height: 32) } + .buttonStyle(.plain) } .padding(.horizontal, 12) .padding(.vertical, 12) .background(Color(.systemGroupedBackground)) + .animation(.smooth(duration: 0.22), value: currentKey) + .onChange(of: currentKey) { _, newValue in + previousKey = newValue + } + } + + private func slideEdge(from prev: ClueKey?, to curr: ClueKey?) -> Edge { + guard let prev, let curr else { return .trailing } + if prev.direction != curr.direction { return .trailing } + return curr.number > prev.number ? .trailing : .leading } private func label(for clue: Puzzle.Clue?) -> String { @@ -147,3 +212,10 @@ private struct RebusModal: View { .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } } + +private extension VerticalAlignment { + enum ClueCenterID: AlignmentID { + static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] } + } + static let clueCenter = VerticalAlignment(ClueCenterID.self) +}