crossmate

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

commit 989419b0d641c27fc1194b68b70feac6f74b07ce
parent 8816715185f936efd8cb5182b242203d5be38854
Author: Michael Camilleri <[email protected]>
Date:   Wed, 15 Apr 2026 02:04:05 +0900

Refactor to use @FetchRequest

Diffstat:
MCrossmate/CrossmateApp.swift | 6+-----
MCrossmate/Persistence/GameStore.swift | 144+++++++++++++++++++++++++++++++------------------------------------------------
MCrossmate/Views/GameListView.swift | 67+++++++++++++++++++++++++------------------------------------------
MCrossmate/Views/GridThumbnailView.swift | 4++--
MCrossmate/Views/NewGameSheet.swift | 4+---
5 files changed, 86 insertions(+), 139 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -33,6 +33,7 @@ struct CrossmateApp: App { syncMonitor: syncMonitor, appDelegate: appDelegate ) + .environment(\.managedObjectContext, persistence.viewContext) .environment(nytAuth) .environment(ubiquityMonitor) .environment(\.nytPuzzleFetcher, nytFetcher) @@ -75,7 +76,6 @@ struct RootView: View { @Environment(\.scenePhase) private var scenePhase @AppStorage("playerColorID") private var playerColorID: String = PlayerColor.blue.id @State private var syncBootstrapped = false - @State private var lastVisitedGameID: UUID? @State private var navigationPath = NavigationPath() var body: some View { @@ -85,7 +85,6 @@ struct RootView: View { syncEngine: syncEngine, syncMonitor: syncMonitor, appDelegate: appDelegate, - lastVisitedGameID: $lastVisitedGameID, navigationPath: $navigationPath ) .navigationDestination(for: UUID.self) { gameID in @@ -95,9 +94,6 @@ struct RootView: View { syncEngine: syncEngine, syncMonitor: syncMonitor ) - .onAppear { - lastVisitedGameID = gameID - } } } .environment(\.playerColor, PlayerColor.color(for: playerColorID)) diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -2,82 +2,28 @@ import CoreData import Foundation import Observation -/// 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 { - let persistence: PersistenceController - private var context: NSManagedObjectContext { persistence.viewContext } - - private(set) var currentGame: Game? - private(set) var currentMutator: GameMutator? - private(set) var currentEntity: GameEntity? - - init(persistence: PersistenceController) { - self.persistence = persistence - } - - 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 puzzleDate: Date? - 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] { - repairSyncedGamesIfNeeded() - - 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) - } +/// Per-cell state for rendering a thumbnail. Plain value type so +/// SwiftUI can diff it cheaply. +enum GameThumbnailCell: Equatable { + case block + case empty + case filled +} - private func makeSummary(from entity: GameEntity) -> GameSummary? { +/// Value type backing a library row. Built from a `GameEntity` so that +/// SwiftUI's `@FetchRequest` can drive the list and still render through +/// an immutable, diff-friendly model. +struct GameSummary: Identifiable, Equatable { + let id: UUID + let title: String + let puzzleDate: Date? + let updatedAt: Date? + let completedAt: Date? + let gridWidth: Int + let gridHeight: Int + let thumbnailCells: [GameThumbnailCell] + + init?(entity: GameEntity) { guard let id = entity.id, let source = entity.puzzleSource, let xd = try? XD.parse(source) else { @@ -87,13 +33,12 @@ final class GameStore { 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] = [] + var thumbCells: [GameThumbnailCell] = [] thumbCells.reserveCapacity(puzzle.width * puzzle.height) for r in 0..<puzzle.height { for c in 0..<puzzle.width { @@ -107,16 +52,41 @@ final class GameStore { } } - return GameSummary( - id: id, - title: entity.title ?? "Untitled", - puzzleDate: puzzle.date, - updatedAt: entity.updatedAt, - completedAt: entity.completedAt, - gridWidth: puzzle.width, - gridHeight: puzzle.height, - thumbnailCells: thumbCells - ) + self.id = id + self.title = entity.title ?? "Untitled" + self.puzzleDate = puzzle.date + self.updatedAt = entity.updatedAt + self.completedAt = entity.completedAt + self.gridWidth = puzzle.width + self.gridHeight = puzzle.height + self.thumbnailCells = thumbCells + } +} + +/// Repository over the local Core Data store. Manages the lifecycle of +/// games — loading a specific one, creating new ones from bundled puzzles, +/// and deleting them. The library list itself is driven by `@FetchRequest` +/// in `GameListView`, not this type. Persistence of individual cell +/// mutations is handled by `GameMutator`. +@MainActor +@Observable +final class GameStore { + let persistence: PersistenceController + private var context: NSManagedObjectContext { persistence.viewContext } + + private(set) var currentGame: Game? + private(set) var currentMutator: GameMutator? + private(set) var currentEntity: GameEntity? + + init(persistence: PersistenceController) { + self.persistence = persistence + repairSyncedGamesIfNeeded() + } + + enum LoadError: Error { + case sampleResourceMissing + case persistedSourceMissing + case gameNotFound } // MARK: - Load a specific game diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -1,3 +1,4 @@ +import CoreData import SwiftUI struct GameListView: View { @@ -5,19 +6,35 @@ struct GameListView: View { let syncEngine: SyncEngine let syncMonitor: SyncMonitor let appDelegate: AppDelegate - @Binding var lastVisitedGameID: UUID? @Binding var navigationPath: NavigationPath - @State private var games: [GameStore.GameSummary] = [] + @Environment(\.managedObjectContext) private var viewContext + @FetchRequest( + sortDescriptors: [], + animation: .default + ) + private var games: FetchedResults<GameEntity> + @State private var showingNewGame = false @State private var showingSettings = false - @State private var deleteTarget: GameStore.GameSummary? - @State private var resignTarget: GameStore.GameSummary? - @State private var loaded = false + @State private var deleteTarget: GameSummary? + @State private var resignTarget: GameSummary? + + private var inProgress: [GameSummary] { + games.compactMap(GameSummary.init(entity:)) + .filter { $0.completedAt == nil } + .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + } + + private var completed: [GameSummary] { + games.compactMap(GameSummary.init(entity:)) + .filter { $0.completedAt != nil } + .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } + } var body: some View { Group { - if games.isEmpty && loaded { + if games.isEmpty { ContentUnavailableView { Label("No Puzzles", systemImage: "square.grid.3x3") } description: { @@ -25,9 +42,6 @@ struct GameListView: View { } } else { List { - let inProgress = games.filter { $0.completedAt == nil } - let completed = games.filter { $0.completedAt != nil } - if !inProgress.isEmpty { Section { ForEach(inProgress) { game in @@ -102,11 +116,7 @@ struct GameListView: View { SettingsView(syncEngine: syncEngine, syncMonitor: syncMonitor) } .sheet(isPresented: $showingNewGame) { - NewGameSheet(store: store) { id in - if let summary = store.gameSummary(forID: id) { - games.insert(summary, at: 0) - } - } + NewGameSheet(store: store) } .alert("Resign Puzzle?", isPresented: .init( get: { resignTarget != nil }, @@ -115,10 +125,6 @@ struct GameListView: View { 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) {} @@ -134,7 +140,6 @@ struct GameListView: View { Button("Delete", role: .destructive) { if let target = deleteTarget { try? store.deleteGame(id: target.id) - games.removeAll { $0.id == target.id } } } Button("Cancel", role: .cancel) {} @@ -143,35 +148,13 @@ struct GameListView: View { 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 + let game: GameSummary var onResume: () -> Void = {} var onResign: () -> Void = {} var onDelete: () -> Void = {} diff --git a/Crossmate/Views/GridThumbnailView.swift b/Crossmate/Views/GridThumbnailView.swift @@ -6,7 +6,7 @@ import SwiftUI struct GridThumbnailView: View { let width: Int let height: Int - let cells: [GameStore.ThumbnailCell] + let cells: [GameThumbnailCell] private let size: CGFloat = 60 private let spacing: CGFloat = 0.5 @@ -23,7 +23,7 @@ struct GridThumbnailView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) } - private func fillColor(for cell: GameStore.ThumbnailCell) -> Color { + private func fillColor(for cell: GameThumbnailCell) -> Color { switch cell { case .block: return .black case .empty: return .white diff --git a/Crossmate/Views/NewGameSheet.swift b/Crossmate/Views/NewGameSheet.swift @@ -2,7 +2,6 @@ import SwiftUI struct NewGameSheet: View { let store: GameStore - let onCreated: (UUID) -> Void @Environment(\.dismiss) private var dismiss @Environment(NYTAuthService.self) private var nytAuth @@ -90,8 +89,7 @@ struct NewGameSheet: View { private func create(from source: String) { do { - let id = try store.createGame(from: source) - onCreated(id) + _ = try store.createGame(from: source) dismiss() } catch { createError = error.localizedDescription