From 5c0fc8ee2d07f72a86f1a1dbe6ce09fc5e5b9786 Mon Sep 17 00:00:00 2001 From: Trey T Date: Fri, 15 May 2026 15:44:43 -0500 Subject: [PATCH] =?UTF-8?q?Vocab=20Practice=20=E2=80=94=20proper=20SRS=20s?= =?UTF-8?q?ession=20queue=20with=20in-session=20learning=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vocab Flashcards / Multiple Choice were a flat linear walk through a shuffled list — rating a card "Hard" or "Again" did nothing to bring it back. Replaced with a real two-layer SRS, matching how Anki separates in-session learning steps from the long-term schedule. VocabSessionQueue (new) — the in-session layer. Position-based learning steps: - Again → card reappears 5–8 cards later (state: learning) - Hard → reappears 7–10 cards later (state: learning) - Good → first time: → review state, reappears 16–24 cards later already in review: graduates, leaves the session - Easy → graduates immediately A card you keep failing keeps cycling until you mark it Good twice or Easy once. answer() returns a ReviewQuality only on graduation — that's the single rating handed to the long-term VerbReviewStore, so intermediate Again/Hard presses no longer thrash the cross-session SM-2 schedule. VocabVerbPool.sessionVerbs (rewritten) — due-first ordering + a 20-card session cap. Overdue verbs (per VerbReviewCard.dueDate) come first, most-overdue leading; then never-reviewed verbs by frequency rank. Not-yet-due verbs are intentionally skipped — that's the SRS schedule doing its job. A single sitting is now bounded instead of a 100+ card slog. Study Again — the completion screen gets a "Study Again" button that rebuilds the queue from the same verb set (re-shuffled), so you can run the whole set again after finishing. Progress display switched from "1 of 110" to "N learned · M to go", which reflects the live queue as cards requeue and graduate. Both vocab views now share VocabSessionQueue + VocabVerbPool; the queue struct is pure value-type logic, easy to reason about and test. Co-Authored-By: Claude Opus 4.7 (1M context) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 4 + .../Conjuga/Services/VocabSessionQueue.swift | 175 ++++++++++++++++++ .../Vocab/VocabFlashcardPracticeView.swift | 151 +++++++-------- .../VocabMultipleChoicePracticeView.swift | 111 +++++++---- 4 files changed, 324 insertions(+), 117 deletions(-) create mode 100644 Conjuga/Conjuga/Services/VocabSessionQueue.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 1b8e55c..8b42897 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; }; 0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; }; 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; }; + 13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */; }; 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; }; 14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; }; 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; }; @@ -270,6 +271,7 @@ F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = ""; }; FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = ""; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = ""; }; + FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = ""; }; FF3475931F1AD16054741E65 /* BookChapterListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -367,6 +369,7 @@ 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */, A661ADF1141176EE96774138 /* BookSpeechController.swift */, A2ACC4C35491174257770941 /* VerbReviewStore.swift */, + FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */, ); path = Services; sourceTree = ""; @@ -799,6 +802,7 @@ 419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */, 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */, 5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */, + 13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Conjuga/Conjuga/Services/VocabSessionQueue.swift b/Conjuga/Conjuga/Services/VocabSessionQueue.swift new file mode 100644 index 0000000..f73ecb9 --- /dev/null +++ b/Conjuga/Conjuga/Services/VocabSessionQueue.swift @@ -0,0 +1,175 @@ +import Foundation +import SharedModels +import SwiftData + +/// In-session "learning steps" queue for vocab practice — the short-term +/// scheduling layer that sits on top of the cross-session SM-2 schedule. +/// +/// A card is requeued a relative number of positions ahead based on the +/// rating, mirroring Anki's learning steps but position-based instead of +/// time-based: +/// Again → reappears 5–8 cards later +/// Hard → reappears 7–10 cards later +/// Good → first time: advances to `review`, reappears ~20 cards later +/// already in review: graduates (leaves the session) +/// Easy → graduates immediately +/// +/// `answer` returns a `ReviewQuality` only when the card graduates — that's +/// the single rating fed to the long-term `VerbReviewStore`. Intermediate +/// Again/Hard presses don't touch the cross-session schedule. +struct VocabSessionQueue { + + enum CardState { + case new // never answered this session + case learning // answered Again/Hard at least once + case review // answered Good once, one confirmation pass to go + } + + enum Rating { + case again, hard, good, easy + } + + struct Entry: Identifiable { + let id = UUID() + let verb: Verb + var state: CardState + } + + private(set) var queue: [Entry] + private(set) var learnedCount: Int = 0 + private let originalVerbs: [Verb] + + init(verbs: [Verb]) { + originalVerbs = verbs + queue = verbs.map { Entry(verb: $0, state: .new) } + } + + // MARK: - State + + var current: Entry? { queue.first } + var isComplete: Bool { queue.isEmpty } + var remainingCount: Int { queue.count } + + /// 0–1, climbs as cards graduate. Requeuing a card lowers it slightly but + /// it always trends to 1 as the session drains. + var progress: Double { + let total = learnedCount + queue.count + return total == 0 ? 1 : Double(learnedCount) / Double(total) + } + + // MARK: - Answering + + /// Apply a rating to the current card. Returns the `ReviewQuality` to + /// record in the long-term SRS *iff* the card graduated; nil if it was + /// requeued for more in-session practice. + @discardableResult + mutating func answer(_ rating: Rating) -> ReviewQuality? { + guard !queue.isEmpty else { return nil } + var entry = queue.removeFirst() + + switch rating { + case .again: + entry.state = .learning + insert(entry, offset: Int.random(in: 5...8)) + return nil + + case .hard: + entry.state = .learning + insert(entry, offset: Int.random(in: 7...10)) + return nil + + case .good: + if entry.state == .review { + learnedCount += 1 + return .good + } + entry.state = .review + insert(entry, offset: Int.random(in: 16...24)) + return nil + + case .easy: + learnedCount += 1 + return .easy + } + } + + /// Rebuild the session from the same verb set (re-shuffled) — "Study Again". + mutating func restart() { + queue = originalVerbs.shuffled().map { Entry(verb: $0, state: .new) } + learnedCount = 0 + } + + // MARK: - Private + + /// Insert `entry` `offset` positions from the front, i.e. `offset` other + /// cards will be shown before it reappears. Clamps to the queue's end. + private mutating func insert(_ entry: Entry, offset: Int) { + let idx = min(queue.count, offset) + queue.insert(entry, at: idx) + } +} + +// MARK: - Session verb pool + +/// Builds a vocab-practice session: level-filtered verbs ordered due-first +/// (per the cross-session `VerbReviewCard` schedule) and capped so a single +/// sitting is bounded — proper SRS behaviour rather than a 100+ card slog. +enum VocabVerbPool { + + /// Maximum verbs in one session. Overdue cards are pulled first, then new + /// (never-reviewed) verbs fill the rest. + static let sessionCardLimit = 20 + + static func sessionVerbs( + localContext: ModelContext, + cloudContext: ModelContext + ) -> [Verb] { + let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) + let levels = Set(progress.selectedVerbLevels.map(\.rawValue)) + + let store = ReferenceStore(context: localContext) + let pool = levels.isEmpty + ? store.fetchVerbs() + : store.fetchVerbs(selectedLevels: levels) + + let reviewCards = (try? cloudContext.fetch(FetchDescriptor())) ?? [] + let cardByVerbId = Dictionary( + reviewCards.map { ($0.verbId, $0) }, + uniquingKeysWith: { existing, _ in existing } + ) + + let now = Date() + var due: [(verb: Verb, dueDate: Date)] = [] + var fresh: [Verb] = [] + for verb in pool { + if let card = cardByVerbId[verb.id] { + if card.dueDate <= now { + due.append((verb, card.dueDate)) + } + // Not yet due → intentionally skipped; that's the SRS schedule. + } else { + fresh.append(verb) + } + } + + // Most-overdue first, then new verbs (lower rank = more common first). + due.sort { $0.dueDate < $1.dueDate } + fresh.sort { $0.rank < $1.rank } + + let ordered = due.map(\.verb) + fresh + return Array(ordered.prefix(sessionCardLimit)) + } +} + +/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema +/// requires exactly 6 examples, so callers must pass 6 distinct tense IDs. +enum VocabExampleTenseIds { + static let canonical: [String] = [ + TenseID.ind_presente.rawValue, + TenseID.ind_preterito.rawValue, + TenseID.ind_imperfecto.rawValue, + TenseID.ind_futuro.rawValue, + TenseID.subj_presente.rawValue, + TenseID.imp_afirmativo.rawValue, + ] +} diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift index 2737817..7d51e0f 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift @@ -2,21 +2,20 @@ import SwiftUI import SharedModels import SwiftData -/// English-first verb flashcard. Pool = all verbs whose `level` is enabled -/// in Settings (UserProgress.selectedVerbLevels) — the same filter that -/// drives the conjugation practice modes. +/// English-first verb flashcard, driven by `VocabSessionQueue` — an in-session +/// learning-step queue. Front: verb.english. Tap to reveal verb.infinitive, a +/// lazy-generated example sentence, and SRS rating buttons. /// -/// Front: verb.english (e.g. "to run"). Tap to reveal verb.infinitive, -/// an AI-generated illustration, a lazy-generated example sentence, -/// and SRS rating buttons. +/// Again/Hard requeue the card a few cards later; Good moves it toward the end; +/// a second Good or an Easy graduates it. The long-term SM-2 schedule +/// (VerbReviewStore) is updated only when a card graduates. struct VocabFlashcardPracticeView: View { @Environment(\.modelContext) private var localContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(VerbExampleCache.self) private var exampleCache @Environment(\.dismiss) private var dismiss - @State private var verbs: [Verb] = [] - @State private var index: Int = 0 + @State private var session: VocabSessionQueue? @State private var revealed: Bool = false @State private var exampleByVerbId: [Int: VerbExample] = [:] @State private var generatingExampleForVerbId: Int? = nil @@ -24,10 +23,7 @@ struct VocabFlashcardPracticeView: View { private var cloudContext: ModelContext { cloudModelContextProvider() } - private var currentVerb: Verb? { - guard index < verbs.count else { return nil } - return verbs[index] - } + private var currentVerb: Verb? { session?.current?.verb } var body: some View { ScrollView { @@ -46,23 +42,25 @@ struct VocabFlashcardPracticeView: View { .navigationBarTitleDisplayMode(.inline) .onAppear(perform: loadIfNeeded) .animation(.smooth, value: revealed) - .animation(.smooth, value: index) + .animation(.smooth, value: currentVerb?.id) } // MARK: - Progress private var progressBar: some View { VStack(spacing: 6) { - ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count)) + ProgressView(value: session?.progress ?? 0) .tint(.purple) - Text(verbs.isEmpty ? noPoolMessage : "\(min(index + 1, verbs.count)) of \(verbs.count)") + Text(progressLabel) .font(.caption) .foregroundStyle(.secondary) } } - private var noPoolMessage: String { - "No verbs match the levels enabled in Settings" + private var progressLabel: String { + guard let session else { return "Loading…" } + if session.isComplete { return "Done" } + return "\(session.learnedCount) learned · \(session.remainingCount) to go" } // MARK: - Card @@ -152,17 +150,17 @@ struct VocabFlashcardPracticeView: View { .font(.subheadline) .foregroundStyle(.secondary) HStack(spacing: 10) { - ratingButton("Again", color: .red, quality: .again) - ratingButton("Hard", color: .orange, quality: .hard) - ratingButton("Good", color: .green, quality: .good) - ratingButton("Easy", color: .blue, quality: .easy) + ratingButton("Again", color: .red, rating: .again) + ratingButton("Hard", color: .orange, rating: .hard) + ratingButton("Good", color: .green, rating: .good) + ratingButton("Easy", color: .blue, rating: .easy) } } } - private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View { + private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View { Button { - rateAndAdvance(quality) + answer(rating) } label: { Text(label) .font(.subheadline.weight(.semibold)) @@ -180,24 +178,67 @@ struct VocabFlashcardPracticeView: View { Image(systemName: "checkmark.circle.fill") .font(.system(size: 56)) .foregroundStyle(.green) - Text("Session Complete") + Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due") .font(.title2.bold()) - Text("\(verbs.count) verbs reviewed") + Text(completionDetail) .font(.subheadline) .foregroundStyle(.secondary) - Button("Done") { dismiss() } + .multilineTextAlignment(.center) + + HStack(spacing: 12) { + Button { + studyAgain() + } label: { + Label("Study Again", systemImage: "arrow.clockwise") + .font(.subheadline.weight(.semibold)) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + } .buttonStyle(.borderedProminent) .tint(.purple) - .padding(.top, 12) + + Button("Done") { dismiss() } + .font(.subheadline.weight(.semibold)) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .buttonStyle(.bordered) + } + .padding(.top, 12) } .padding(.top, 60) + .padding(.horizontal, 24) + } + + private var completionDetail: String { + let learned = session?.learnedCount ?? 0 + if learned > 0 { + return "\(learned) verb\(learned == 1 ? "" : "s") learned" + } + return "No verbs are due right now. Study Again to review anyway." } // MARK: - Logic private func loadIfNeeded() { - guard verbs.isEmpty else { return } - verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext) + guard session == nil else { return } + let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) + session = VocabSessionQueue(verbs: verbs) + primeExampleForCurrent() + } + + private func studyAgain() { + session?.restart() + revealed = false + primeExampleForCurrent() + } + + private func answer(_ rating: VocabSessionQueue.Rating) { + guard let verbId = currentVerb?.id else { return } + let graduation = session?.answer(rating) ?? nil + if let graduation { + VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation) + } + withAnimation(.smooth) { revealed = false } primeExampleForCurrent() } @@ -205,12 +246,10 @@ struct VocabFlashcardPracticeView: View { guard let verb = currentVerb else { return } if exampleByVerbId[verb.id] != nil { return } - // Cache hit? if let cached = exampleCache.examples(for: verb.id)?.first { exampleByVerbId[verb.id] = cached return } - // Otherwise lazy-generate (no blocking on tap-to-reveal). guard VerbExampleGenerator.isAvailable else { return } generatingExampleForVerbId = verb.id let verbId = verb.id @@ -220,10 +259,6 @@ struct VocabFlashcardPracticeView: View { .conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical) Task { do { - // The generator's @Generable schema requires exactly 6 - // examples; pass the canonical 6-tense set used by - // VerbDetailView, then pick the present-tense one to - // show on the card. let examples = try await VerbExampleGenerator.generate( verbInfinitive: infinitive, verbEnglish: english, @@ -243,50 +278,4 @@ struct VocabFlashcardPracticeView: View { } } } - - private func rateAndAdvance(_ quality: ReviewQuality) { - guard let verb = currentVerb else { return } - VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality) - withAnimation(.smooth) { - revealed = false - index += 1 - } - primeExampleForCurrent() - } -} - -/// Canonical 6-tense set used by `VerbExampleGenerator`. Its `@Generable` -/// schema requires exactly 6 examples; callers must pass 6 distinct tense -/// IDs so the model has a unique slot for each generated example. -enum VocabExampleTenseIds { - static let canonical: [String] = [ - TenseID.ind_presente.rawValue, - TenseID.ind_preterito.rawValue, - TenseID.ind_imperfecto.rawValue, - TenseID.ind_futuro.rawValue, - TenseID.subj_presente.rawValue, - TenseID.imp_afirmativo.rawValue, - ] -} - -// MARK: - Pool helper - -/// Shared verb-pool fetch used by both vocab flashcard and vocab MC. -/// Reads `UserProgress.selectedVerbLevels` from the cloud context and -/// filters the local Verb table by those levels — the exact same path -/// `PracticeSessionService` already uses. -enum VocabVerbPool { - static func fetch(localContext: ModelContext, cloudContext: ModelContext) -> [Verb] { - let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) - let levels = progress.selectedVerbLevels.map(\.rawValue) - let levelSet = Set(levels) - let store = ReferenceStore(context: localContext) - let pool: [Verb] - if levelSet.isEmpty { - pool = store.fetchVerbs() - } else { - pool = store.fetchVerbs(selectedLevels: levelSet) - } - return pool.shuffled() - } } diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift index 957e2fa..9011074 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift @@ -2,19 +2,18 @@ import SwiftUI import SharedModels import SwiftData -/// English-first verb multiple choice. Pool = verbs whose `level` is enabled -/// in Settings (UserProgress.selectedVerbLevels). 4 options shown, 1 correct -/// + 3 random distractors from the same pool. After answer: reveal correct/ -/// incorrect, the verb infinitive, an AI illustration, an example sentence, -/// and SRS rating buttons. +/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options +/// (1 correct + 3 random distractors from the session pool). After answering: +/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS +/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates. struct VocabMultipleChoicePracticeView: View { @Environment(\.modelContext) private var localContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(VerbExampleCache.self) private var exampleCache @Environment(\.dismiss) private var dismiss - @State private var verbs: [Verb] = [] - @State private var index: Int = 0 + @State private var session: VocabSessionQueue? + @State private var distractorPool: [Verb] = [] @State private var options: [Verb] = [] @State private var selectedOption: Verb? = nil @State private var exampleByVerbId: [Int: VerbExample] = [:] @@ -23,10 +22,7 @@ struct VocabMultipleChoicePracticeView: View { private var cloudContext: ModelContext { cloudModelContextProvider() } - private var currentVerb: Verb? { - guard index < verbs.count else { return nil } - return verbs[index] - } + private var currentVerb: Verb? { session?.current?.verb } var body: some View { ScrollView { @@ -45,21 +41,27 @@ struct VocabMultipleChoicePracticeView: View { .navigationBarTitleDisplayMode(.inline) .onAppear(perform: loadIfNeeded) .animation(.smooth, value: selectedOption?.id) - .animation(.smooth, value: index) + .animation(.smooth, value: currentVerb?.id) } // MARK: - Progress private var progressBar: some View { VStack(spacing: 6) { - ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count)) + ProgressView(value: session?.progress ?? 0) .tint(.purple) - Text(verbs.isEmpty ? "No verbs match the levels enabled in Settings" : "\(min(index + 1, verbs.count)) of \(verbs.count)") + Text(progressLabel) .font(.caption) .foregroundStyle(.secondary) } } + private var progressLabel: String { + guard let session else { return "Loading…" } + if session.isComplete { return "Done" } + return "\(session.learnedCount) learned · \(session.remainingCount) to go" + } + // MARK: - Question @ViewBuilder @@ -157,17 +159,17 @@ struct VocabMultipleChoicePracticeView: View { .font(.subheadline) .foregroundStyle(.secondary) HStack(spacing: 10) { - ratingButton("Again", color: .red, quality: .again) - ratingButton("Hard", color: .orange, quality: .hard) - ratingButton("Good", color: .green, quality: .good) - ratingButton("Easy", color: .blue, quality: .easy) + ratingButton("Again", color: .red, rating: .again) + ratingButton("Hard", color: .orange, rating: .hard) + ratingButton("Good", color: .green, rating: .good) + ratingButton("Easy", color: .blue, rating: .easy) } } } - private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View { + private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View { Button { - rateAndAdvance(quality) + answer(rating) } label: { Text(label) .font(.subheadline.weight(.semibold)) @@ -185,35 +187,81 @@ struct VocabMultipleChoicePracticeView: View { Image(systemName: "checkmark.circle.fill") .font(.system(size: 56)) .foregroundStyle(.green) - Text("Session Complete") + Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due") .font(.title2.bold()) - Text("\(verbs.count) verbs reviewed") + Text(completionDetail) .font(.subheadline) .foregroundStyle(.secondary) - Button("Done") { dismiss() } + .multilineTextAlignment(.center) + + HStack(spacing: 12) { + Button { + studyAgain() + } label: { + Label("Study Again", systemImage: "arrow.clockwise") + .font(.subheadline.weight(.semibold)) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + } .buttonStyle(.borderedProminent) .tint(.purple) - .padding(.top, 12) + + Button("Done") { dismiss() } + .font(.subheadline.weight(.semibold)) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .buttonStyle(.bordered) + } + .padding(.top, 12) } .padding(.top, 60) + .padding(.horizontal, 24) + } + + private var completionDetail: String { + let learned = session?.learnedCount ?? 0 + if learned > 0 { + return "\(learned) verb\(learned == 1 ? "" : "s") learned" + } + return "No verbs are due right now. Study Again to review anyway." } // MARK: - Logic private func loadIfNeeded() { - guard verbs.isEmpty else { return } - verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext) + guard session == nil else { return } + let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) + distractorPool = verbs + session = VocabSessionQueue(verbs: verbs) + prepareOptions() + primeExampleForCurrent() + } + + private func studyAgain() { + session?.restart() + selectedOption = nil prepareOptions() primeExampleForCurrent() } private func prepareOptions() { guard let verb = currentVerb else { options = []; return } - let candidates = verbs.filter { $0.id != verb.id } + let candidates = distractorPool.filter { $0.id != verb.id } let distractors = Array(candidates.shuffled().prefix(3)) options = ([verb] + distractors).shuffled() } + private func answer(_ rating: VocabSessionQueue.Rating) { + guard let verbId = currentVerb?.id else { return } + let graduation = session?.answer(rating) ?? nil + if let graduation { + VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation) + } + selectedOption = nil + prepareOptions() + primeExampleForCurrent() + } + private func primeExampleForCurrent() { guard let verb = currentVerb else { return } if exampleByVerbId[verb.id] != nil { return } @@ -247,13 +295,4 @@ struct VocabMultipleChoicePracticeView: View { } } } - - private func rateAndAdvance(_ quality: ReviewQuality) { - guard let verb = currentVerb else { return } - VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality) - index += 1 - selectedOption = nil - prepareOptions() - primeExampleForCurrent() - } }