commit f3182bd155c30cfde0cfd32d7bb17b9181177a07
parent 961f6b8a7565dd5f36958d01b4195c31a9650de0
Author: Michael Camilleri <[email protected]>
Date: Sat, 11 Apr 2026 19:20:45 +0900
Add puzzle catalog
This commit adds a puzzle catalog as the new root view for the app. The
catalog shows the games currently in progress as well as those that have
been completed. It also allows a user to start a new game (two
additional demo games are included to test this).
Diffstat:
11 files changed, 791 insertions(+), 80 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; };
+ 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; };
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
@@ -17,18 +18,23 @@
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; };
+ 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; };
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; };
83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */; };
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; };
97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; };
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
+ AA992F67F509EC8EFDDFC7CB /* morning.xd in Resources */ = {isa = PBXBuildFile; fileRef = 0B73A791FD061430AE286E11 /* morning.xd */; };
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
+ B42454D72FAA219D60DEA334 /* garden.xd in Resources */ = {isa = PBXBuildFile; fileRef = 50992CDA4082429EBB17F65C /* garden.xd */; };
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 */; };
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; };
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */; };
+ DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; };
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; };
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; };
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; };
@@ -46,11 +52,15 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
+ 0B73A791FD061430AE286E11 /* morning.xd */ = {isa = PBXFileReference; path = morning.xd; sourceTree = "<group>"; };
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
+ 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; };
465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
+ 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; };
+ 50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; };
5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; };
5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeTests.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
@@ -70,10 +80,12 @@
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.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>"; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; };
E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangePayload.swift; sourceTree = "<group>"; };
E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PendingChange+Helpers.swift"; sourceTree = "<group>"; };
+ F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; };
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; };
F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -127,6 +139,7 @@
DB55FC337CF72C650373210A /* PlayerColor.swift */,
20B331CC55827FEF3420ABCE /* PlayerSession.swift */,
64C8064F04FC6177D987ACA2 /* Puzzle.swift */,
+ 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */,
DB851649DE78AAAC5A928C52 /* Square.swift */,
B9031A1574C21866940F6A2C /* XD.swift */,
F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */,
@@ -164,8 +177,11 @@
isa = PBXGroup;
children = (
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */,
+ 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */,
+ D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */,
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */,
7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */,
+ F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */,
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */,
);
path = Views;
@@ -184,6 +200,8 @@
F53443E4827221C62DB7AA36 /* Resources */ = {
isa = PBXGroup;
children = (
+ 50992CDA4082429EBB17F65C /* garden.xd */,
+ 0B73A791FD061430AE286E11 /* morning.xd */,
5C63A148D98E2D37EABF2CF5 /* sample.xd */,
);
path = Resources;
@@ -272,6 +290,8 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ B42454D72FAA219D60DEA334 /* garden.xd in Resources */,
+ AA992F67F509EC8EFDDFC7CB /* morning.xd in Resources */,
97D77230A98330DCB757FA81 /* sample.xd in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -299,16 +319,20 @@
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */,
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */,
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */,
+ 818B1F2693962832BE14578E /* GameListView.swift in Sources */,
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */,
D58980B92C99122C368D4216 /* GameStore.swift in Sources */,
+ C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */,
765B50552B13175F91A25EA1 /* GridView.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
+ DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */,
D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */,
C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */,
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */,
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */,
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */,
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
+ 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -59,26 +59,28 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var syncBootstrapped = false
- @State private var session: PlayerSession?
- @State private var loadError: String?
+ @State private var lastVisitedGameID: UUID?
+ @State private var navigationPath = NavigationPath()
var body: some View {
- NavigationStack {
- Group {
- if let session {
- PuzzleView(session: session)
- } else if let loadError {
- ContentUnavailableView(
- "Couldn't load puzzle",
- systemImage: "exclamationmark.triangle",
- description: Text(loadError)
- )
- } else {
- ProgressView()
+ NavigationStack(path: $navigationPath) {
+ GameListView(
+ store: store,
+ syncEngine: syncEngine,
+ appDelegate: appDelegate,
+ lastVisitedGameID: $lastVisitedGameID,
+ navigationPath: $navigationPath
+ )
+ .navigationDestination(for: UUID.self) { gameID in
+ GameDestinationView(
+ gameID: gameID,
+ store: store,
+ syncEngine: syncEngine
+ )
+ .onAppear {
+ lastVisitedGameID = gameID
}
}
- .navigationTitle(session?.puzzle.title ?? "Crossmate")
- .navigationBarTitleDisplayMode(.inline)
}
.task {
guard !syncBootstrapped else { return }
@@ -89,30 +91,15 @@ struct RootView: View {
try? await syncEngine.fetchChanges()
}
- // Wire sync engine → store refresh
- await syncEngine.setOnRemoteChangesApplied { [store] in
- store.refreshFromRemote()
+ // Wire sync engine → store → mutator (single inbox)
+ await syncEngine.setOnRemoteCellChanges { [store] changes in
+ store.applyRemoteChanges(changes)
}
// Bootstrap and initial sync
try? await syncEngine.bootstrap()
try? await syncEngine.fetchChanges()
try? await syncEngine.pushChanges()
-
- // Load the current game
- do {
- let (game, mutator) = try store.loadOrCreateCurrentGame()
- session = PlayerSession(game: game, mutator: mutator)
-
- // Wire mutator → sync engine push
- mutator.onLocalMutation = { [syncEngine] in
- Task {
- try? await syncEngine.pushChanges()
- }
- }
- } catch {
- loadError = String(describing: error)
- }
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
@@ -124,3 +111,50 @@ struct RootView: View {
}
}
}
+
+// MARK: - Game Destination
+
+/// Loads a game when navigated to and sets up sync wiring for it.
+private struct GameDestinationView: View {
+ let gameID: UUID
+ let store: GameStore
+ let syncEngine: SyncEngine
+
+ @State private var session: PlayerSession?
+ @State private var loadError: String?
+
+ init(gameID: UUID, store: GameStore, syncEngine: SyncEngine) {
+ self.gameID = gameID
+ self.store = store
+ self.syncEngine = syncEngine
+
+ do {
+ let (game, mutator) = try store.loadGame(id: gameID)
+ let playerSession = PlayerSession(game: game, mutator: mutator)
+ mutator.onLocalMutation = { [syncEngine] in
+ Task {
+ try? await syncEngine.pushChanges()
+ }
+ }
+ self._session = State(initialValue: playerSession)
+ } catch {
+ self._loadError = State(initialValue: String(describing: error))
+ }
+ }
+
+ var body: some View {
+ Group {
+ if let session {
+ PuzzleView(session: session)
+ } else if let loadError {
+ ContentUnavailableView(
+ "Couldn't load puzzle",
+ systemImage: "exclamationmark.triangle",
+ description: Text(loadError)
+ )
+ }
+ }
+ .navigationTitle(session?.puzzle.title ?? "")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
diff --git a/Crossmate/Models/PuzzleCatalog.swift b/Crossmate/Models/PuzzleCatalog.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+/// Knows about all bundled `.xd` puzzle resources. Used by the new-game
+/// picker to list available puzzles.
+struct PuzzleCatalog {
+ struct Entry: Identifiable {
+ let id: String // resource name (e.g. "sample")
+ let title: String // parsed from the XD metadata
+ let source: String // raw XD text
+ }
+
+ static func bundledPuzzles() -> [Entry] {
+ guard let urls = Bundle.main.urls(forResourcesWithExtension: "xd", subdirectory: nil) else {
+ return []
+ }
+ return urls.compactMap { url in
+ let name = url.deletingPathExtension().lastPathComponent
+ guard let source = try? String(contentsOf: url, encoding: .utf8),
+ let xd = try? XD.parse(source) else {
+ return nil
+ }
+ return Entry(id: name, title: xd.title ?? name, source: source)
+ }
+ .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
+ }
+}
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -1,6 +1,19 @@
import CoreData
import Foundation
+/// Decoded cell state from an incoming CloudKit record, passed from the
+/// `SyncEngine` actor to the main actor for application through `GameMutator`.
+struct RemoteCellChange: Sendable {
+ let gameRecordName: String
+ let row: Int
+ let col: Int
+ let letter: String
+ let markKind: Int16
+ let checkedWrong: Bool
+ let updatedAt: Date?
+ let letterAuthorID: String?
+}
+
/// Unified mutation processor that sits between `PlayerSession` (or the sync
/// engine) and `Game`. Every mutation flows through here so that:
///
@@ -119,6 +132,29 @@ final class GameMutator {
}
}
+ // MARK: - Remote cell application
+
+ /// Applies a single remote cell change to the in-memory `Game`. Core Data
+ /// is already up to date (written by `RecordSerializer`), so this only
+ /// touches the in-memory model after passing the LWW gate.
+ func applyRemoteCell(_ change: RemoteCellChange) {
+ let row = change.row
+ let col = change.col
+ guard row >= 0, row < game.puzzle.height, col >= 0, col < game.puzzle.width else { return }
+
+ // LWW: only apply if the remote timestamp is present and at least as
+ // new as the local one.
+ let localTimestamp = game.squares[row][col].updatedAt
+ guard let remoteTimestamp = change.updatedAt,
+ localTimestamp == nil || remoteTimestamp >= localTimestamp!
+ else { return }
+
+ game.squares[row][col].entry = change.letter
+ game.squares[row][col].mark = decodeMark(kind: change.markKind, checkedWrong: change.checkedWrong)
+ game.squares[row][col].updatedAt = change.updatedAt
+ game.squares[row][col].letterAuthorID = change.letterAuthorID
+ }
+
// MARK: - Helpers
private func timestamp(for origin: Origin) -> Date {
@@ -178,6 +214,15 @@ final class GameMutator {
}
}
+ private func decodeMark(kind: Int16, checkedWrong: Bool) -> CellMark {
+ switch kind {
+ case 1: return .pen(checkedWrong: checkedWrong)
+ case 2: return .pencil(checkedWrong: checkedWrong)
+ case 3: return .revealed
+ default: return .none
+ }
+ }
+
private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedWrong: Bool) {
switch mark {
case .none:
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -2,10 +2,10 @@ import CoreData
import Foundation
import Observation
-/// Repository over the local Core Data store. Owns the lifecycle of the
-/// current game — loading it on launch (or seeding from `sample.xd` on
-/// first run). Persistence of individual cell mutations is handled by
-/// `GameMutator`.
+/// Repository over the local Core Data store. Manages the lifecycle of
+/// games — listing them, loading a specific one, creating new ones from
+/// bundled puzzles, and deleting them. Persistence of individual cell
+/// mutations is handled by `GameMutator`.
@MainActor
@Observable
final class GameStore {
@@ -23,8 +23,201 @@ final class GameStore {
enum LoadError: Error {
case sampleResourceMissing
case persistedSourceMissing
+ case gameNotFound
}
+ // MARK: - Game list
+
+ /// Per-cell state for rendering a thumbnail. Plain value type so
+ /// SwiftUI can diff it cheaply.
+ enum ThumbnailCell: Equatable {
+ case block
+ case empty
+ case filled
+ }
+
+ struct GameSummary: Identifiable, Equatable {
+ let id: UUID
+ let title: String
+ let updatedAt: Date?
+ let completedAt: Date?
+ /// Grid dimensions + cell states for the thumbnail.
+ let gridWidth: Int
+ let gridHeight: Int
+ let thumbnailCells: [ThumbnailCell]
+ }
+
+ /// Fetches all games, sorted: incomplete first (by `updatedAt` DESC),
+ /// then completed (by `completedAt` DESC).
+ func listGames() throws -> [GameSummary] {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
+ let entities = try context.fetch(request)
+
+ let summaries = entities.compactMap { makeSummary(from: $0) }
+
+ // Incomplete first, then completed
+ let incomplete = summaries.filter { $0.completedAt == nil }
+ let completed = summaries.filter { $0.completedAt != nil }
+ .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
+
+ return incomplete + completed
+ }
+
+ /// Builds a fresh summary for a single game. Returns nil if the game
+ /// no longer exists.
+ func gameSummary(forID id: UUID) -> GameSummary? {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first else { return nil }
+ return makeSummary(from: entity)
+ }
+
+ private func makeSummary(from entity: GameEntity) -> GameSummary? {
+ guard let id = entity.id,
+ let source = entity.puzzleSource,
+ let xd = try? XD.parse(source) else {
+ return nil
+ }
+
+ let puzzle = Puzzle(xd: xd)
+ let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
+
+ // Build a lookup of which cells have entries
+ var filledSet: Set<Int> = []
+ for ce in cellEntities where !(ce.letter ?? "").isEmpty {
+ filledSet.insert(Int(ce.row) * puzzle.width + Int(ce.col))
+ }
+
+ var thumbCells: [ThumbnailCell] = []
+ thumbCells.reserveCapacity(puzzle.width * puzzle.height)
+ for r in 0..<puzzle.height {
+ for c in 0..<puzzle.width {
+ if puzzle.cells[r][c].isBlock {
+ thumbCells.append(.block)
+ } else if filledSet.contains(r * puzzle.width + c) {
+ thumbCells.append(.filled)
+ } else {
+ thumbCells.append(.empty)
+ }
+ }
+ }
+
+ return GameSummary(
+ id: id,
+ title: entity.title ?? "Untitled",
+ updatedAt: entity.updatedAt,
+ completedAt: entity.completedAt,
+ gridWidth: puzzle.width,
+ gridHeight: puzzle.height,
+ thumbnailCells: thumbCells
+ )
+ }
+
+ // MARK: - Load a specific game
+
+ /// Loads a game by its entity ID. Sets it as the current game.
+ func loadGame(id: UUID) throws -> (Game, GameMutator) {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+
+ guard let entity = try context.fetch(request).first else {
+ throw LoadError.gameNotFound
+ }
+ guard let source = entity.puzzleSource else {
+ throw LoadError.persistedSourceMissing
+ }
+
+ let xd = try XD.parse(source)
+ let puzzle = Puzzle(xd: xd)
+ let game = Game(puzzle: puzzle)
+ restore(game: game, from: entity)
+
+ let mutator = GameMutator(game: game, gameEntity: entity, context: context)
+
+ currentGame = game
+ currentMutator = mutator
+ currentEntity = entity
+
+ return (game, mutator)
+ }
+
+ // MARK: - Create a new game
+
+ /// Creates a new game from XD source text. Returns the new game's UUID.
+ func createGame(from source: String) throws -> UUID {
+ let xd = try XD.parse(source)
+ let puzzle = Puzzle(xd: xd)
+
+ let now = Date()
+ let gameID = UUID()
+ let entity = GameEntity(context: context)
+ entity.id = gameID
+ entity.title = puzzle.title
+ entity.puzzleSource = source
+ entity.createdAt = now
+ entity.updatedAt = now
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+
+ for row in puzzle.cells {
+ for cell in row where !cell.isBlock {
+ let cellEntity = CellEntity(context: context)
+ cellEntity.row = Int16(cell.row)
+ cellEntity.col = Int16(cell.col)
+ cellEntity.letter = ""
+ cellEntity.markKind = 0
+ cellEntity.checkedWrong = false
+ cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)"
+ cellEntity.game = entity
+ }
+ }
+
+ try context.save()
+ return gameID
+ }
+
+ // MARK: - Delete a game
+
+ func deleteGame(id: UUID) throws {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+
+ guard let entity = try context.fetch(request).first else { return }
+
+ // Clear current references if this is the active game
+ if currentEntity?.id == id {
+ currentGame = nil
+ currentMutator = nil
+ currentEntity = nil
+ }
+
+ context.delete(entity)
+ try context.save()
+ }
+
+ // MARK: - Resign a game
+
+ /// Reveals all cells and marks the game as completed (resigned).
+ func resignGame(id: UUID) throws {
+ let (game, mutator) = try loadGame(id: id)
+ let allCells = game.puzzle.cells.flatMap { $0 }
+ mutator.revealCells(allCells, origin: .local)
+
+ guard let entity = currentEntity else { return }
+ entity.completedAt = Date()
+ try context.save()
+
+ // Clean up current references
+ currentGame = nil
+ currentMutator = nil
+ currentEntity = nil
+ }
+
+ // MARK: - Legacy convenience
+
/// Returns the single current game and its mutator, creating from
/// `sample.xd` on first launch. Subsequent launches rehydrate the
/// in-memory `Game` from the stored `CellEntity` rows so any prior
@@ -113,37 +306,20 @@ final class GameStore {
}
}
- // MARK: - Remote refresh
-
- /// Re-reads CellEntity values from Core Data into the in-memory Game.
- /// Called on the MainActor after the sync engine applies remote changes
- /// to the background context (which merges into viewContext via
- /// `automaticallyMergesChangesFromParent`).
- func refreshFromRemote() {
- guard let game = currentGame, let entity = currentEntity else { return }
+ // MARK: - Remote changes
- // Fault the relationship to pick up merged background changes
- context.refresh(entity, mergeChanges: true)
+ /// Routes incoming remote cell changes through the current `GameMutator`,
+ /// filtering to only the currently loaded game. Core Data is already up to
+ /// date at this point (written by `RecordSerializer` on the background
+ /// context); this method updates the in-memory `Game` via the single-inbox
+ /// path so that hooks like summary regeneration only need one attachment
+ /// point.
+ func applyRemoteChanges(_ changes: [RemoteCellChange]) {
+ guard let mutator = currentMutator, let entity = currentEntity else { return }
+ let gameRecordName = entity.ckRecordName ?? ""
- let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
- for cellEntity in cellEntities {
- let r = Int(cellEntity.row)
- let c = Int(cellEntity.col)
- guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue }
-
- let remoteTimestamp = cellEntity.updatedAt
- let remoteAuthorID = cellEntity.letterAuthorID
- let remoteLetter = cellEntity.letter ?? ""
- let remoteMark = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong)
-
- // Only apply if the remote timestamp is newer (LWW)
- let localTimestamp = game.squares[r][c].updatedAt
- if let remote = remoteTimestamp, (localTimestamp == nil || remote >= localTimestamp!) {
- game.squares[r][c].entry = remoteLetter
- game.squares[r][c].mark = remoteMark
- game.squares[r][c].updatedAt = remoteTimestamp
- game.squares[r][c].letterAuthorID = remoteAuthorID
- }
+ for change in changes where change.gameRecordName == gameRecordName {
+ mutator.applyRemoteCell(change)
}
}
diff --git a/Crossmate/Resources/garden.xd b/Crossmate/Resources/garden.xd
@@ -0,0 +1,19 @@
+Title: Garden Party
+Author: Crossmate
+Copyright: Public domain test puzzle
+
+
+##B##
+#ALE#
+GROVE
+#TOE#
+##M##
+
+
+A2. Beer or lager ~ ALE
+A4. Cluster of trees ~ GROVE
+A5. Foot digit ~ TOE
+
+D1. Full flower ~ BLOOM
+D2. Painting or sculpture ~ ART
+D3. Christmas ___ ~ EVE
diff --git a/Crossmate/Resources/morning.xd b/Crossmate/Resources/morning.xd
@@ -0,0 +1,19 @@
+Title: Morning Routine
+Author: Crossmate
+Copyright: Public domain test puzzle
+
+
+##S##
+#ATE#
+BREAK
+#TAR#
+##M##
+
+
+A2. Had breakfast ~ ATE
+A4. Fracture ~ BREAK
+A5. Road surface substance ~ TAR
+
+D1. Water vapor ~ STEAM
+D2. Gallery exhibit ~ ART
+D3. Body part for hearing ~ EAR
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -12,12 +12,13 @@ actor SyncEngine {
private let zoneID: CKRecordZone.ID
- /// Called on the MainActor after remote changes have been applied to
- /// Core Data. Wired up in CrossmateApp to call GameStore.refreshFromRemote.
- private var onRemoteChangesApplied: (@MainActor @Sendable () -> Void)?
+ /// Called on the MainActor with decoded cell changes after remote records
+ /// have been applied to Core Data. Wired up in CrossmateApp to route
+ /// through GameStore → GameMutator (the single inbox).
+ private var onRemoteCellChanges: (@MainActor @Sendable ([RemoteCellChange]) -> Void)?
- func setOnRemoteChangesApplied(_ callback: @MainActor @Sendable @escaping () -> Void) {
- onRemoteChangesApplied = callback
+ func setOnRemoteCellChanges(_ callback: @MainActor @Sendable @escaping ([RemoteCellChange]) -> Void) {
+ onRemoteCellChanges = callback
}
init(container: CKContainer, persistence: PersistenceController) {
@@ -112,6 +113,7 @@ actor SyncEngine {
/// (the CloudKit per-operation limit).
func pushChanges() async throws {
let context = persistence.container.newBackgroundContext()
+ var serverWinsCellChanges: [RemoteCellChange] = []
while true {
let batch: [(recordName: String, recordType: String, payload: PendingChangePayload, systemFields: Data?)] =
@@ -177,16 +179,22 @@ actor SyncEngine {
self.deletePendingChange(recordName: recordName, in: context)
case .failure(let error):
- self.handlePushError(
+ let changes = self.handlePushError(
error: error,
recordName: recordName,
in: context
)
+ serverWinsCellChanges.append(contentsOf: changes)
}
}
try? context.save()
}
}
+
+ // Route any server-wins cell changes through the single inbox
+ if let onRemoteCellChanges, !serverWinsCellChanges.isEmpty {
+ await onRemoteCellChanges(serverWinsCellChanges)
+ }
}
// MARK: - Fetch
@@ -219,15 +227,18 @@ actor SyncEngine {
guard !incomingRecords.isEmpty else { return }
+ // Extract cell changes before applying (reads directly from CKRecords)
+ let cellChanges = incomingRecords.compactMap { Self.extractCellChange(from: $0) }
+
// Step 3: Apply incoming records to Core Data
context.performAndWait {
self.applyIncomingRecords(incomingRecords, in: context)
try? context.save()
}
- // Step 4: Hop to MainActor to refresh the in-memory Game
- if let onRemoteChangesApplied {
- await onRemoteChangesApplied()
+ // Step 4: Route cell changes through the single inbox
+ if let onRemoteCellChanges, !cellChanges.isEmpty {
+ await onRemoteCellChanges(cellChanges)
}
}
@@ -405,14 +416,17 @@ actor SyncEngine {
}
}
+ /// Returns any `RemoteCellChange` values that resulted from the server
+ /// winning a conflict (so the caller can route them through the single
+ /// inbox on the main actor).
private nonisolated func handlePushError(
error: Error,
recordName: String,
in context: NSManagedObjectContext
- ) {
+ ) -> [RemoteCellChange] {
guard let ckError = error as? CKError else {
print("SyncEngine: non-CK error pushing \(recordName): \(error)")
- return
+ return []
}
switch ckError.code {
@@ -421,7 +435,7 @@ actor SyncEngine {
// merge using LWW on our `updatedAt` field.
guard let serverRecord = ckError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord else {
print("SyncEngine: serverRecordChanged but no server record for \(recordName)")
- return
+ return []
}
// Read the pending change to get our local values
@@ -432,7 +446,7 @@ actor SyncEngine {
let payloadJSON = pending.payload,
let data = payloadJSON.data(using: .utf8),
let localPayload = try? JSONDecoder().decode(PendingChangePayload.self, from: data)
- else { return }
+ else { return [] }
let serverUpdatedAt = serverRecord["updatedAt"] as? Date ?? .distantPast
let localUpdatedAt = localPayload.updatedAt ?? .distantPast
@@ -446,12 +460,14 @@ actor SyncEngine {
// The pending change stays in the outbox; next loop iteration
// will pick it up with the updated system fields.
_ = systemFields
+ return []
} else {
// Server wins — drop our pending change and apply the server
// record to Core Data.
context.delete(pending)
if serverRecord.recordType == "Game" {
let _ = RecordSerializer.applyGameRecord(serverRecord, to: context)
+ return []
} else {
// Find the parent game for the cell
if let parentRef = serverRecord.parent {
@@ -462,6 +478,10 @@ actor SyncEngine {
let _ = RecordSerializer.applyCellRecord(serverRecord, to: context, game: gameEntity)
}
}
+ if let change = Self.extractCellChange(from: serverRecord) {
+ return [change]
+ }
+ return []
}
}
@@ -469,6 +489,34 @@ actor SyncEngine {
// For other errors (network, throttle, etc.), leave the pending
// change in the outbox for the next push attempt.
print("SyncEngine: error pushing \(recordName): \(ckError)")
+ return []
}
}
+
+ // MARK: - Remote cell change extraction
+
+ /// Decodes the cell-relevant fields from a `CKRecord` into a value type
+ /// that can be sent to the main actor.
+ private static func extractCellChange(from record: CKRecord) -> RemoteCellChange? {
+ guard record.recordType == "Cell" else { return nil }
+ guard let parentRef = record.parent else { return nil }
+
+ // Parse row/col from record name (format: "cell-<uuid>-<row>-<col>")
+ let parts = record.recordID.recordName.split(separator: "-")
+ guard parts.count >= 2,
+ let row = Int(parts[parts.count - 2]),
+ let col = Int(parts[parts.count - 1])
+ else { return nil }
+
+ return RemoteCellChange(
+ gameRecordName: parentRef.recordID.recordName,
+ row: row,
+ col: col,
+ letter: record["letter"] as? String ?? "",
+ markKind: record["markKind"] as? Int16 ?? 0,
+ checkedWrong: record["checkedWrong"] as? Bool ?? false,
+ updatedAt: record["updatedAt"] as? Date,
+ letterAuthorID: record["letterAuthorID"] as? String
+ )
+ }
}
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -0,0 +1,203 @@
+import SwiftUI
+
+struct GameListView: View {
+ let store: GameStore
+ let syncEngine: SyncEngine
+ let appDelegate: AppDelegate
+ @Binding var lastVisitedGameID: UUID?
+ @Binding var navigationPath: NavigationPath
+
+ @State private var games: [GameStore.GameSummary] = []
+ @State private var showingNewGame = false
+ @State private var deleteTarget: GameStore.GameSummary?
+ @State private var resignTarget: GameStore.GameSummary?
+ @State private var loaded = false
+
+ var body: some View {
+ Group {
+ if games.isEmpty && loaded {
+ ContentUnavailableView {
+ Label("No Puzzles", systemImage: "square.grid.3x3")
+ } description: {
+ Text("Tap the + button to start a new puzzle.")
+ }
+ } else {
+ List {
+ let inProgress = games.filter { $0.completedAt == nil }
+ let completed = games.filter { $0.completedAt != nil }
+
+ if !inProgress.isEmpty {
+ Section("In Progress") {
+ ForEach(inProgress) { game in
+ GameRowView(game: game, onResume: {
+ navigationPath.append(game.id)
+ }, onResign: {
+ resignTarget = game
+ }, onDelete: {
+ deleteTarget = game
+ })
+ .background(
+ NavigationLink(value: game.id) { EmptyView() }
+ .opacity(0)
+ )
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button("Delete", role: .destructive) {
+ deleteTarget = game
+ }
+ }
+ }
+ }
+ }
+
+ if !completed.isEmpty {
+ Section("Completed") {
+ ForEach(completed) { game in
+ GameRowView(game: game, onResume: {
+ navigationPath.append(game.id)
+ }, onResign: {
+ resignTarget = game
+ }, onDelete: {
+ deleteTarget = game
+ })
+ .background(
+ NavigationLink(value: game.id) { EmptyView() }
+ .opacity(0)
+ )
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button("Delete", role: .destructive) {
+ deleteTarget = game
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("Crossmate")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showingNewGame = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ .sheet(isPresented: $showingNewGame) {
+ NewGameSheet(store: store) { id in
+ if let summary = store.gameSummary(forID: id) {
+ games.insert(summary, at: 0)
+ }
+ }
+ }
+ .alert("Resign Puzzle?", isPresented: .init(
+ get: { resignTarget != nil },
+ set: { if !$0 { resignTarget = nil } }
+ )) {
+ Button("Resign", role: .destructive) {
+ if let target = resignTarget {
+ try? store.resignGame(id: target.id)
+ if let updated = store.gameSummary(forID: target.id),
+ let index = games.firstIndex(where: { $0.id == target.id }) {
+ games[index] = updated
+ }
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ if let target = resignTarget {
+ Text("This will reveal all answers for \"\(target.title)\".")
+ }
+ }
+ .alert("Delete Puzzle?", isPresented: .init(
+ get: { deleteTarget != nil },
+ set: { if !$0 { deleteTarget = nil } }
+ )) {
+ Button("Delete", role: .destructive) {
+ if let target = deleteTarget {
+ try? store.deleteGame(id: target.id)
+ games.removeAll { $0.id == target.id }
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ if let target = deleteTarget {
+ Text("This will permanently delete \"\(target.title)\" and all progress.")
+ }
+ }
+ .task {
+ guard !loaded else { return }
+ reloadAllGames()
+ loaded = true
+ }
+ .onAppear {
+ guard let id = lastVisitedGameID else { return }
+ lastVisitedGameID = nil
+ refreshGame(id: id)
+ }
+ }
+
+ private func reloadAllGames() {
+ games = (try? store.listGames()) ?? []
+ }
+
+ private func refreshGame(id: UUID) {
+ guard let updated = store.gameSummary(forID: id),
+ let index = games.firstIndex(where: { $0.id == id }) else {
+ return
+ }
+ games[index] = updated
+ }
+}
+
+// MARK: - Row
+
+private struct GameRowView: View {
+ let game: GameStore.GameSummary
+ var onResume: () -> Void = {}
+ var onResign: () -> Void = {}
+ var onDelete: () -> Void = {}
+
+ var body: some View {
+ HStack(spacing: 12) {
+ GridThumbnailView(
+ width: game.gridWidth,
+ height: game.gridHeight,
+ cells: game.thumbnailCells
+ )
+ VStack(alignment: .leading, spacing: 2) {
+ Text(game.title)
+ .font(.headline)
+ if let date = game.updatedAt {
+ Text(date, format: Calendar.current.isDateInToday(date) ? .dateTime.hour().minute() : .dateTime.year().month().day())
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Text("Solo")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Menu {
+ Button { } label: { Label("Share", systemImage: "square.and.arrow.up") }
+ .disabled(true)
+ Button { } label: { Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") }
+ .disabled(true)
+ Button { onResume() } label: { Label("Resume", systemImage: "square.and.pencil") }
+ Section {
+ Button(role: .destructive) { onResign() } label: { Label("Resign", systemImage: "flag") }
+ Button(role: .destructive) { onDelete() } label: { Label("Delete", systemImage: "trash") }
+ }
+ } label: {
+ Image(systemName: "ellipsis")
+ .font(.body)
+ .frame(width: 32, height: 32)
+ .contentShape(Rectangle())
+ }
+ .tint(.secondary)
+ .compositingGroup()
+ }
+ .padding(.vertical, 4)
+ }
+}
diff --git a/Crossmate/Views/GridThumbnailView.swift b/Crossmate/Views/GridThumbnailView.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+
+/// A miniature, non-interactive rendering of a crossword grid for use in
+/// game list rows. Each cell is a colored rectangle — black for blocks,
+/// white for empty cells, tinted for cells with entries.
+struct GridThumbnailView: View {
+ let width: Int
+ let height: Int
+ let cells: [GameStore.ThumbnailCell]
+
+ private let size: CGFloat = 60
+ private let spacing: CGFloat = 0.5
+
+ var body: some View {
+ ThumbnailGridLayout(columns: width, rows: height, spacing: spacing) {
+ ForEach(0..<cells.count, id: \.self) { index in
+ Rectangle()
+ .fill(fillColor(for: cells[index]))
+ }
+ }
+ .frame(width: size, height: size)
+ .background(Color.black)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+
+ private func fillColor(for cell: GameStore.ThumbnailCell) -> Color {
+ switch cell {
+ case .block: return .black
+ case .empty: return .white
+ case .filled: return Color(.systemGray3)
+ }
+ }
+}
+
+// MARK: - Layout
+
+/// Simplified grid layout for thumbnails. Fills the proposed size exactly,
+/// computing cell size from the available space.
+private struct ThumbnailGridLayout: Layout {
+ let columns: Int
+ let rows: Int
+ let spacing: CGFloat
+
+ func sizeThatFits(
+ proposal: ProposedViewSize,
+ subviews: Subviews,
+ cache: inout ()
+ ) -> CGSize {
+ let w = proposal.width ?? 60
+ let h = proposal.height ?? 60
+ return CGSize(width: w, height: h)
+ }
+
+ func placeSubviews(
+ in bounds: CGRect,
+ proposal: ProposedViewSize,
+ subviews: Subviews,
+ cache: inout ()
+ ) {
+ let cols = CGFloat(columns)
+ let rs = CGFloat(rows)
+ let totalSpacingW = spacing * (cols + 1)
+ let totalSpacingH = spacing * (rs + 1)
+ let cellW = (bounds.width - totalSpacingW) / cols
+ let cellH = (bounds.height - totalSpacingH) / rs
+ let cellSize = min(cellW, cellH)
+
+ let gridW = cellSize * cols + spacing * (cols + 1)
+ let gridH = cellSize * rs + spacing * (rs + 1)
+ let originX = bounds.minX + (bounds.width - gridW) / 2
+ let originY = bounds.minY + (bounds.height - gridH) / 2
+
+ let cellProposal = ProposedViewSize(width: cellSize, height: cellSize)
+ for (index, subview) in subviews.enumerated() {
+ let r = index / columns
+ let c = index % columns
+ let x = originX + spacing + CGFloat(c) * (cellSize + spacing)
+ let y = originY + spacing + CGFloat(r) * (cellSize + spacing)
+ subview.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: cellProposal)
+ }
+ }
+}
diff --git a/Crossmate/Views/NewGameSheet.swift b/Crossmate/Views/NewGameSheet.swift
@@ -0,0 +1,35 @@
+import SwiftUI
+
+struct NewGameSheet: View {
+ let store: GameStore
+ let onCreated: (UUID) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+
+ private var puzzles: [PuzzleCatalog.Entry] {
+ PuzzleCatalog.bundledPuzzles()
+ }
+
+ var body: some View {
+ NavigationStack {
+ List(puzzles) { entry in
+ Button {
+ if let id = try? store.createGame(from: entry.source) {
+ onCreated(id)
+ dismiss()
+ }
+ } label: {
+ Text(entry.title)
+ .foregroundStyle(.primary)
+ }
+ }
+ .navigationTitle("New Puzzle")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ }
+ }
+}