crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 24++++++++++++++++++++++++
MCrossmate/CrossmateApp.swift | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
ACrossmate/Models/PuzzleCatalog.swift | 26++++++++++++++++++++++++++
MCrossmate/Persistence/GameMutator.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Persistence/GameStore.swift | 242++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
ACrossmate/Resources/garden.xd | 19+++++++++++++++++++
ACrossmate/Resources/morning.xd | 19+++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
ACrossmate/Views/GameListView.swift | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Views/GridThumbnailView.swift | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Views/NewGameSheet.swift | 35+++++++++++++++++++++++++++++++++++
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() } + } + } + } + } +}