Vocab study — per-type session sizes + Review Learned multiple choice
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" }
|
||||
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 lexemes: [Lexeme]
|
||||
switch kind {
|
||||
case .standard:
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let lexemes = LexemePool.sessionLexemes(
|
||||
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",
|
||||
|
||||
@@ -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" }
|
||||
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 lexemes: [Lexeme]
|
||||
switch kind {
|
||||
case .standard:
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let lexemes = LexemePool.sessionLexemes(
|
||||
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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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<Int>) -> 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
|
||||
|
||||
Reference in New Issue
Block a user