import Foundation import ManaCore import Observation import SwiftData /// State-Machine für eine Lern-Session. Lädt Due-Reviews beim Start, /// rendert eine Karte nach der anderen, schickt Grades via GradeQueue ab. /// /// Seit ζ-1 (2026-05-18): wenn der Server-Call scheitert, fällt die /// Session auf den `CachedDueReview`-Snapshot vom letzten Sync zurück. /// Der User lernt dann offline. Grades laufen wie immer in die /// `GradeQueue` und drainen beim Reconnect. @MainActor @Observable final class StudySession { enum Phase { case loading case studying case finished case failed(String) } private(set) var phase: Phase = .loading private(set) var queue: [DueReview] = [] private(set) var currentIndex: Int = 0 private(set) var isFlipped: Bool = false private(set) var totalGraded: Int = 0 /// `true` wenn die Session aus dem lokalen Snapshot statt vom Server /// gestartet wurde. View kann ein Offline-Banner zeigen. private(set) var isOfflineSession: Bool = false let deckId: String let deckName: String private let api: WordeckAPI private let context: ModelContext private let gradeQueue: GradeQueue init(deckId: String, deckName: String, auth: AuthClient, context: ModelContext) { self.deckId = deckId self.deckName = deckName self.context = context api = WordeckAPI(auth: auth) gradeQueue = GradeQueue(api: api, context: context) } var current: DueReview? { guard queue.indices.contains(currentIndex) else { return nil } return queue[currentIndex] } var remaining: Int { max(0, queue.count - currentIndex) } func start() async { phase = .loading do { queue = try await api.dueReviews(deckId: deckId, limit: 500) currentIndex = 0 isFlipped = false totalGraded = 0 isOfflineSession = false if queue.isEmpty { phase = .finished } else { phase = .studying } let count = queue.count let id = deckId Log.study.info("Session start — \(count, privacy: .public) due in deck \(id, privacy: .public)") } catch { // Server nicht erreichbar oder Auth-Fehler → Cache-Fallback. queue = loadFromCache() currentIndex = 0 isFlipped = false totalGraded = 0 if queue.isEmpty { let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) phase = .failed(msg) Log.study.error("Session start failed (no cache): \(msg, privacy: .public)") } else { isOfflineSession = true phase = .studying let count = queue.count let id = deckId Log.study .notice("Offline-Session — \(count, privacy: .public) cached due in deck \(id, privacy: .public)") } } } private func loadFromCache() -> [DueReview] { let deckId = deckId var descriptor = FetchDescriptor( predicate: #Predicate { $0.deckId == deckId }, sortBy: [SortDescriptor(\.due, order: .forward)] ) descriptor.fetchLimit = 500 let cached = (try? context.fetch(descriptor)) ?? [] return cached.compactMap { $0.toDueReview() } } func flip() { guard case .studying = phase else { return } isFlipped.toggle() } func grade(_ rating: Rating) async { guard case .studying = phase, let card = current else { return } let reviewedAt = Date.now await gradeQueue.submit( cardId: card.review.cardId, subIndex: card.review.subIndex, rating: rating, reviewedAt: reviewedAt ) totalGraded += 1 advance() } private func advance() { currentIndex += 1 isFlipped = false if currentIndex >= queue.count { phase = .finished let count = totalGraded Log.study.info("Session finished — graded \(count, privacy: .public)") } } }