commit 989419b0d641c27fc1194b68b70feac6f74b07ce
parent 8816715185f936efd8cb5182b242203d5be38854
Author: Michael Camilleri <[email protected]>
Date: Wed, 15 Apr 2026 02:04:05 +0900
Refactor to use @FetchRequest
Diffstat:
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