From aab64116b3b1ff743c2acf9118bdff9192667aba Mon Sep 17 00:00:00 2001 From: Trey T Date: Mon, 1 Jun 2026 23:54:10 -0500 Subject: [PATCH] =?UTF-8?q?Vocab=20study=20=E2=80=94=20per-type=20session?= =?UTF-8?q?=20sizes=20+=20Review=20Learned=20multiple=20choice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings: split the single session-size picker into separate Verbs / Nouns / Adjectives pickers. Nouns and adjectives previously shared one hidden limit; they now use nounSessionCardLimit / adjectiveSessionCardLimit. - LexemePool.sessionCardLimit is now per part-of-speech. - Multiple-choice views (verb/noun/adjective) gained a kind param so Review Learned can run as multiple choice, not just flashcards. The cram pass drives the in-session queue only and leaves the long-term SRS schedule untouched. - PracticeView: each section now offers Review Learned — Flashcards and Review Learned — Multiple Choice. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Conjuga/Services/LexemeSessionQueue.swift | 18 +++++-- .../Conjuga/Views/Practice/PracticeView.swift | 36 ++++++++++++-- .../AdjectiveMultipleChoicePracticeView.swift | 47 ++++++++++++++----- .../NounMultipleChoicePracticeView.swift | 47 ++++++++++++++----- .../VocabMultipleChoicePracticeView.swift | 26 ++++++++-- .../Conjuga/Views/Settings/SettingsView.swift | 24 ++++++---- 6 files changed, 154 insertions(+), 44 deletions(-) diff --git a/Conjuga/Conjuga/Services/LexemeSessionQueue.swift b/Conjuga/Conjuga/Services/LexemeSessionQueue.swift index 08bbe88..08624b2 100644 --- a/Conjuga/Conjuga/Services/LexemeSessionQueue.swift +++ b/Conjuga/Conjuga/Services/LexemeSessionQueue.swift @@ -114,9 +114,19 @@ struct LexemeSessionQueue { /// `LexemeReviewCard`, then fresh (never-studied) lexemes, capped. enum LexemePool { - /// Per-session cap. 0/unset → 20. Mirrors `VocabVerbPool.sessionCardLimit`. - static var sessionCardLimit: Int { - let stored = UserDefaults.standard.integer(forKey: "lexemeSessionCardLimit") + /// Per-session cap for a part of speech, from its "Cards per session" + /// setting. Nouns read `nounSessionCardLimit`, adjectives + /// `adjectiveSessionCardLimit`; anything else falls back to the legacy + /// shared `lexemeSessionCardLimit`. 0/unset → 20. Mirrors + /// `VocabVerbPool.sessionCardLimit`. + static func sessionCardLimit(for partOfSpeech: String) -> Int { + let key: String + switch partOfSpeech { + case "noun": key = "nounSessionCardLimit" + case "adjective": key = "adjectiveSessionCardLimit" + default: key = "lexemeSessionCardLimit" + } + let stored = UserDefaults.standard.integer(forKey: key) return stored == 0 ? 20 : stored } @@ -170,7 +180,7 @@ enum LexemePool { } let ordered = due.map(\.lexeme) + fresh - return Array(ordered.prefix(sessionCardLimit)) + return Array(ordered.prefix(sessionCardLimit(for: partOfSpeech))) } /// Lexemes the user has already studied at least once for `(POS, drill)`, diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index 950fa44..c8b069e 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -439,12 +439,22 @@ struct PracticeView: View { VocabFlashcardPracticeView(kind: .reviewLearned) } label: { practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple, - title: "Review Learned", + title: "Review Learned — Flashcards", subtitle: "Re-review verbs you've studied — schedule unchanged") } .tint(.primary) .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + NavigationLink { + VocabMultipleChoicePracticeView(kind: .reviewLearned) + } label: { + practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple, + title: "Review Learned — Multiple Choice", + subtitle: "Multiple choice over verbs you've studied — schedule unchanged") + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + // Existing: Vocab Review (due cards) NavigationLink { VocabReviewView() @@ -516,12 +526,22 @@ struct PracticeView: View { NounFlashcardPracticeView(kind: .reviewLearned) } label: { practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal, - title: "Review Learned", + title: "Review Learned — Flashcards", subtitle: "Re-review nouns you've studied — schedule unchanged") } .tint(.primary) .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + NavigationLink { + NounMultipleChoicePracticeView(kind: .reviewLearned) + } label: { + practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal, + title: "Review Learned — Multiple Choice", + subtitle: "Multiple choice over nouns you've studied — schedule unchanged") + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + NavigationLink { NounReviewView() } label: { @@ -591,12 +611,22 @@ struct PracticeView: View { AdjectiveFlashcardPracticeView(kind: .reviewLearned) } label: { practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink, - title: "Review Learned", + title: "Review Learned — Flashcards", subtitle: "Re-review adjectives you've studied — schedule unchanged") } .tint(.primary) .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + NavigationLink { + AdjectiveMultipleChoicePracticeView(kind: .reviewLearned) + } label: { + practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink, + title: "Review Learned — Multiple Choice", + subtitle: "Multiple choice over adjectives you've studied — schedule unchanged") + } + .tint(.primary) + .glassEffect(in: RoundedRectangle(cornerRadius: 14)) + NavigationLink { AdjectiveReviewView() } label: { diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveMultipleChoicePracticeView.swift index b24e860..883b812 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveMultipleChoicePracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/AdjectiveMultipleChoicePracticeView.swift @@ -7,6 +7,8 @@ import SwiftData /// adjective pool; 4 options (1 correct + 3 random distractors from the /// session). Options are bare base forms — agreement isn't drilled here. struct AdjectiveMultipleChoicePracticeView: View { + var kind: LexemeSessionKind = .standard + @Environment(\.modelContext) private var localContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.dismiss) private var dismiss @@ -33,7 +35,7 @@ struct AdjectiveMultipleChoicePracticeView: View { .padding() .adaptiveContainer(maxWidth: 720) } - .navigationTitle("Adjective Multiple Choice") + .navigationTitle(kind == .reviewLearned ? "Review Learned" : "Adjective Multiple Choice") .navigationBarTitleDisplayMode(.inline) .onAppear(perform: loadIfNeeded) .animation(.smooth, value: selectedOption?.id) @@ -185,20 +187,39 @@ struct AdjectiveMultipleChoicePracticeView: View { private var completionDetail: String { let learned = session?.learnedCount ?? 0 - if learned > 0 { return "\(learned) adjective\(learned == 1 ? "" : "s") learned" } - return "No adjectives are due right now. Study Again to review anyway." + if learned > 0 { + let verb = kind == .reviewLearned ? "reviewed" : "learned" + return "\(learned) adjective\(learned == 1 ? "" : "s") \(verb)" + } + switch kind { + case .standard: + return "No adjectives are due right now. Study Again to review anyway." + case .reviewLearned: + return "Finish an adjective session first, then come back to consolidate." + } } private func loadIfNeeded() { guard session == nil else { return } - let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) - let lexemes = LexemePool.sessionLexemes( - partOfSpeech: "adjective", - drillMode: Self.drillMode, - enabledLevels: progress.selectedLexemeLevels, - localContext: localContext, - cloudContext: cloudContext - ) + let lexemes: [Lexeme] + switch kind { + case .standard: + let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) + lexemes = LexemePool.sessionLexemes( + partOfSpeech: "adjective", + drillMode: Self.drillMode, + enabledLevels: progress.selectedLexemeLevels, + localContext: localContext, + cloudContext: cloudContext + ) + case .reviewLearned: + lexemes = LexemePool.reviewLearnedLexemes( + partOfSpeech: "adjective", + drillMode: Self.drillMode, + localContext: localContext, + cloudContext: cloudContext + ) + } distractorPool = lexemes session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode) prepareOptions() @@ -220,7 +241,9 @@ struct AdjectiveMultipleChoicePracticeView: View { private func answer(_ rating: LexemeSessionQueue.Rating) { guard let lexeme = currentLexeme else { return } let graduation = session?.answer(rating) - if let graduation { + // Review Learned is a cram pass — graduation drives the in-session + // queue only; the long-term schedule is left untouched. + if let graduation, kind == .standard { LexemeReviewStore(context: cloudContext).rate( lexemeId: lexeme.id, partOfSpeech: "adjective", diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift index 24eb5a3..646c477 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift @@ -9,6 +9,8 @@ import SwiftData /// el problema), example sentence when present, and Again/Hard/Good/Easy /// rating which drives the `LexemeReviewStore` schedule. struct NounMultipleChoicePracticeView: View { + var kind: LexemeSessionKind = .standard + @Environment(\.modelContext) private var localContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.dismiss) private var dismiss @@ -35,7 +37,7 @@ struct NounMultipleChoicePracticeView: View { .padding() .adaptiveContainer(maxWidth: 720) } - .navigationTitle("Noun Multiple Choice") + .navigationTitle(kind == .reviewLearned ? "Review Learned" : "Noun Multiple Choice") .navigationBarTitleDisplayMode(.inline) .onAppear(perform: loadIfNeeded) .animation(.smooth, value: selectedOption?.id) @@ -193,22 +195,41 @@ struct NounMultipleChoicePracticeView: View { private var completionDetail: String { let learned = session?.learnedCount ?? 0 - if learned > 0 { return "\(learned) noun\(learned == 1 ? "" : "s") learned" } - return "No nouns are due right now. Study Again to review anyway." + if learned > 0 { + let verb = kind == .reviewLearned ? "reviewed" : "learned" + return "\(learned) noun\(learned == 1 ? "" : "s") \(verb)" + } + switch kind { + case .standard: + return "No nouns are due right now. Study Again to review anyway." + case .reviewLearned: + return "Finish a noun session first, then come back to consolidate." + } } // MARK: - Logic private func loadIfNeeded() { guard session == nil else { return } - let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) - let lexemes = LexemePool.sessionLexemes( - partOfSpeech: "noun", - drillMode: Self.drillMode, - enabledLevels: progress.selectedLexemeLevels, - localContext: localContext, - cloudContext: cloudContext - ) + let lexemes: [Lexeme] + switch kind { + case .standard: + let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext) + lexemes = LexemePool.sessionLexemes( + partOfSpeech: "noun", + drillMode: Self.drillMode, + enabledLevels: progress.selectedLexemeLevels, + localContext: localContext, + cloudContext: cloudContext + ) + case .reviewLearned: + lexemes = LexemePool.reviewLearnedLexemes( + partOfSpeech: "noun", + drillMode: Self.drillMode, + localContext: localContext, + cloudContext: cloudContext + ) + } distractorPool = lexemes session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode) prepareOptions() @@ -230,7 +251,9 @@ struct NounMultipleChoicePracticeView: View { private func answer(_ rating: LexemeSessionQueue.Rating) { guard let lexeme = currentLexeme else { return } let graduation = session?.answer(rating) - if let graduation { + // Review Learned is a cram pass — graduation drives the in-session + // queue only; the long-term schedule is left untouched. + if let graduation, kind == .standard { LexemeReviewStore(context: cloudContext).rate( lexemeId: lexeme.id, partOfSpeech: "noun", diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift index cf5d054..2827e13 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift @@ -7,6 +7,8 @@ import SwiftData /// 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 { + var kind: VocabSessionKind = .standard + @Environment(\.modelContext) private var localContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(VerbExampleCache.self) private var exampleCache @@ -37,7 +39,7 @@ struct VocabMultipleChoicePracticeView: View { .padding() .adaptiveContainer(maxWidth: 720) } - .navigationTitle("Vocab Multiple Choice") + .navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Multiple Choice") .navigationBarTitleDisplayMode(.inline) .onAppear(perform: loadIfNeeded) .animation(.smooth, value: selectedOption?.id) @@ -221,16 +223,28 @@ struct VocabMultipleChoicePracticeView: View { private var completionDetail: String { let learned = session?.learnedCount ?? 0 if learned > 0 { - return "\(learned) verb\(learned == 1 ? "" : "s") learned" + let verb = kind == .reviewLearned ? "reviewed" : "learned" + return "\(learned) verb\(learned == 1 ? "" : "s") \(verb)" + } + switch kind { + case .standard: + return "No verbs are due right now. Study Again to review anyway." + case .reviewLearned: + return "Finish a Vocab session first, then come back to consolidate." } - return "No verbs are due right now. Study Again to review anyway." } // MARK: - Logic private func loadIfNeeded() { guard session == nil else { return } - let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) + let verbs: [Verb] + switch kind { + case .standard: + verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext) + case .reviewLearned: + verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext) + } distractorPool = verbs session = VocabSessionQueue(verbs: verbs) prepareOptions() @@ -254,7 +268,9 @@ struct VocabMultipleChoicePracticeView: View { private func answer(_ rating: VocabSessionQueue.Rating) { guard let verbId = currentVerb?.id else { return } let graduation = session?.answer(rating) ?? nil - if let graduation { + // Review Learned is a cram pass — graduation drives the in-session + // queue only; the long-term schedule is left untouched. + if let graduation, kind == .standard { VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation) } selectedOption = nil diff --git a/Conjuga/Conjuga/Views/Settings/SettingsView.swift b/Conjuga/Conjuga/Views/Settings/SettingsView.swift index a319922..731012b 100644 --- a/Conjuga/Conjuga/Views/Settings/SettingsView.swift +++ b/Conjuga/Conjuga/Views/Settings/SettingsView.swift @@ -10,8 +10,10 @@ struct SettingsView: View { @State private var showVosotros: Bool = true @State private var autoFillStem: Bool = false - /// Cards per vocab-practice session. 999 = "All" (no cap). + /// Cards per study session, per word type. 999 = "All" (no cap). @AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20 + @AppStorage("nounSessionCardLimit") private var nounSessionCardLimit: Int = 20 + @AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20 private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999] private let levels = VerbLevel.allCases @@ -47,15 +49,13 @@ struct SettingsView: View { } Section { - Picker("Cards per session", selection: $vocabSessionCardLimit) { - ForEach(vocabSessionSizes, id: \.self) { size in - Text(size == 999 ? "All" : "\(size)").tag(size) - } - } + sessionSizePicker("Verbs per session", selection: $vocabSessionCardLimit) + sessionSizePicker("Nouns per session", selection: $nounSessionCardLimit) + sessionSizePicker("Adjectives per session", selection: $adjectiveSessionCardLimit) } header: { - Text("Vocab Flashcards") + Text("Cards Per Session") } footer: { - Text("How many verbs a Vocab Flashcards session draws. Overdue verbs are pulled first, then new ones.") + Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.") } Section { @@ -172,6 +172,14 @@ struct SettingsView: View { } } + private func sessionSizePicker(_ title: String, selection: Binding) -> some View { + Picker(title, selection: selection) { + ForEach(vocabSessionSizes, id: \.self) { size in + Text(size == 999 ? "All" : "\(size)").tag(size) + } + } + } + private func loadProgress() { let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) progress = resolved