Complete native rewrite of the web-based SoliCards game as a SwiftUI multiplatform app targeting iOS 17+, iPadOS 17+, and macOS 14+. Three solitaire variants (Klondike, Spider, FreeCell) with full game rules, drag & drop, smart zoom layout, 6 themes, 4 difficulty levels, SwiftData persistence, VoiceOver accessibility, and 57 unit tests. Key features: - MVVM + Protocol-Oriented Strategy architecture - DragGesture with coordinate-space hit-testing (long press + drag) - Smart zoom: cards auto-size to fit screen based on deepest column - Landscape: 30% bigger cards with scrollable overflow (iOS) - macOS: 120pt card cap, 92% height buffer for window resizing - Auto-save, game resume, statistics tracking via SwiftData - Privacy manifest, app icon, String Catalog, zero dependencies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
110 lines
3.8 KiB
Swift
110 lines
3.8 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
@MainActor
|
|
final class PersistenceManager {
|
|
private let modelContext: ModelContext
|
|
|
|
init(modelContext: ModelContext) {
|
|
self.modelContext = modelContext
|
|
}
|
|
|
|
// MARK: - Game State
|
|
|
|
func saveGame(variant: GameVariant, difficulty: Difficulty, snapshot: GameSnapshot, elapsedSeconds: Int) {
|
|
// Delete any existing save for this variant
|
|
deleteSavedGame(for: variant)
|
|
|
|
let record = GameRecord(variant: variant, difficulty: difficulty,
|
|
snapshot: snapshot, elapsedSeconds: elapsedSeconds)
|
|
modelContext.insert(record)
|
|
try? modelContext.save()
|
|
}
|
|
|
|
func loadSavedGame(for variant: GameVariant) -> GameRecord? {
|
|
let variantRaw = variant.rawValue
|
|
let descriptor = FetchDescriptor<GameRecord>(
|
|
predicate: #Predicate { $0.variant == variantRaw },
|
|
sortBy: [SortDescriptor(\.lastPlayedDate, order: .reverse)]
|
|
)
|
|
return try? modelContext.fetch(descriptor).first
|
|
}
|
|
|
|
func loadMostRecentGame() -> GameRecord? {
|
|
let descriptor = FetchDescriptor<GameRecord>(
|
|
sortBy: [SortDescriptor(\.lastPlayedDate, order: .reverse)]
|
|
)
|
|
return try? modelContext.fetch(descriptor).first
|
|
}
|
|
|
|
func deleteSavedGame(for variant: GameVariant) {
|
|
let variantRaw = variant.rawValue
|
|
let descriptor = FetchDescriptor<GameRecord>(
|
|
predicate: #Predicate { $0.variant == variantRaw }
|
|
)
|
|
if let records = try? modelContext.fetch(descriptor) {
|
|
for record in records {
|
|
modelContext.delete(record)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Statistics
|
|
|
|
func recordWin(variant: GameVariant, difficulty: Difficulty, score: Int, time: Int) {
|
|
let record = fetchOrCreateStats(variant: variant, difficulty: difficulty)
|
|
record.recordWin(score: score, time: time)
|
|
try? modelContext.save()
|
|
}
|
|
|
|
func recordLoss(variant: GameVariant, difficulty: Difficulty) {
|
|
let record = fetchOrCreateStats(variant: variant, difficulty: difficulty)
|
|
record.recordLoss()
|
|
try? modelContext.save()
|
|
}
|
|
|
|
func fetchStats(variant: GameVariant, difficulty: Difficulty) -> StatsRecord? {
|
|
let variantRaw = variant.rawValue
|
|
let difficultyRaw = difficulty.rawValue
|
|
let descriptor = FetchDescriptor<StatsRecord>(
|
|
predicate: #Predicate { $0.variant == variantRaw && $0.difficulty == difficultyRaw }
|
|
)
|
|
return try? modelContext.fetch(descriptor).first
|
|
}
|
|
|
|
private func fetchOrCreateStats(variant: GameVariant, difficulty: Difficulty) -> StatsRecord {
|
|
if let existing = fetchStats(variant: variant, difficulty: difficulty) {
|
|
return existing
|
|
}
|
|
let record = StatsRecord(variant: variant, difficulty: difficulty)
|
|
modelContext.insert(record)
|
|
return record
|
|
}
|
|
|
|
// MARK: - Preferences
|
|
|
|
func loadPreferences() -> PrefsRecord {
|
|
let descriptor = FetchDescriptor<PrefsRecord>()
|
|
if let existing = try? modelContext.fetch(descriptor).first {
|
|
return existing
|
|
}
|
|
let record = PrefsRecord()
|
|
modelContext.insert(record)
|
|
try? modelContext.save()
|
|
return record
|
|
}
|
|
|
|
func savePreferences(themeId: String, cardFaceStyle: CardFaceStyle,
|
|
cardBackDesign: CardBackDesign, soundEnabled: Bool,
|
|
lastVariant: GameVariant, lastDifficulty: Difficulty) {
|
|
let prefs = loadPreferences()
|
|
prefs.themeId = themeId
|
|
prefs.cardFaceStyle = cardFaceStyle.rawValue
|
|
prefs.cardBackDesign = cardBackDesign.rawValue
|
|
prefs.soundEnabled = soundEnabled
|
|
prefs.lastVariant = lastVariant.rawValue
|
|
prefs.lastDifficulty = lastDifficulty.rawValue
|
|
try? modelContext.save()
|
|
}
|
|
}
|