Vocab Practice — verb pool, Settings level filter, per-verb SRS

Vocab Practice used a deck picker over VocabCard rows. That meant it
ignored the Settings level toggles entirely and operated on a totally
separate vocabulary universe than the conjugation modes. Rewired
end-to-end:

Pool source
  Replaced VocabCard with the Verb table. The pool is now
  ReferenceStore.fetchVerbs(selectedLevels: UserProgress.selectedVerbLevels)
  — the same call PracticeSessionService uses for conjugation. Changes
  to level toggles in Settings (or the Verbs tab, which also writes to
  this field) immediately affect Vocab Practice.

Entry flow
  Deleted VocabPracticeEntryView. Practice → Vocabulary now has two
  direct entries:
    • Vocab Flashcards — verb.english → tap → verb.infinitive
    • Vocab Multiple Choice — verb.english → pick from 4 infinitives
  Both pull from the same level-filtered pool, shuffled.

Per-verb SRS
  New VerbReviewCard @Model (cloud-synced, mirrors CourseReviewCard's
  SM-2 fields but keyed by verbId). VerbReviewStore.rate(verbId:quality:)
  applies the existing SRSEngine. Registered in cloud container schema
  in ConjugaApp.swift.

Example sentences
  Lazy-generated via VerbExampleGenerator on first reveal, cached
  through VerbExampleCache (same path VerbDetailView uses). Empty until
  the example arrives — block hides itself if Apple Intelligence isn't
  available.

AI illustration
  VerbIllustration replaces VocabIllustration; same Image Playground
  pipeline. Cache key uses ("verb", infinitive, english) so verbs and
  course-deck vocab never collide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-13 23:31:12 -05:00
parent 164a0a1bb7
commit c890095610
8 changed files with 305 additions and 340 deletions
+4 -4
View File
@@ -37,7 +37,6 @@
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; }; 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; }; 39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; }; 3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
3F7C308425743919FC4407A8 /* VocabPracticeEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */; };
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; }; 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */; }; 419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */; };
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; }; 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
@@ -50,6 +49,7 @@
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; }; 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; }; 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; }; 5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; }; 5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; }; 5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; }; 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
@@ -238,6 +238,7 @@
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; }; 9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; }; A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; }; A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; }; A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
@@ -254,7 +255,6 @@
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; }; CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; }; D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; }; D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabPracticeEntryView.swift; sourceTree = "<group>"; };
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; }; DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
@@ -369,6 +369,7 @@
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */, 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */, A661ADF1141176EE96774138 /* BookSpeechController.swift */,
3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */, 3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */,
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -376,7 +377,6 @@
1ECAF79E2138DF73BB1F6403 /* Vocab */ = { 1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */,
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */, 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */, 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
); );
@@ -800,9 +800,9 @@
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */, 2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */, C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */, 200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */,
3F7C308425743919FC4407A8 /* VocabPracticeEntryView.swift in Sources */,
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */, 419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */, 12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+2 -2
View File
@@ -71,14 +71,14 @@ struct ConjugaApp: App {
let cloudConfig = ModelConfiguration( let cloudConfig = ModelConfiguration(
"cloud", "cloud",
schema: Schema([ schema: Schema([
ReviewCard.self, CourseReviewCard.self, UserProgress.self, ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, TextbookExerciseAttempt.self, ExtraStudyMark.self,
]), ]),
cloudKitDatabase: .private("iCloud.com.conjuga.app") cloudKitDatabase: .private("iCloud.com.conjuga.app")
) )
cloudContainer = try ModelContainer( cloudContainer = try ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self, for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self, TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
TextbookExerciseAttempt.self, ExtraStudyMark.self, TextbookExerciseAttempt.self, ExtraStudyMark.self,
configurations: cloudConfig configurations: cloudConfig
+27
View File
@@ -55,3 +55,30 @@ final class CourseReviewCard {
self.back = back self.back = back
} }
} }
/// SRS record for verb-level vocab practice (EN ES infinitive recall),
/// separate from per-form `ReviewCard` so a user's vocab progress doesn't
/// collide with conjugation form mastery.
///
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
/// `VerbReviewStore` since CloudKit forbids `@Attribute(.unique)`.
@Model
final class VerbReviewCard {
var id: String = ""
var verbId: Int = 0
var easeFactor: Double = 2.5
var interval: Int = 0
var repetitions: Int = 0
var dueDate: Date = Date()
var lastReviewDate: Date?
init(verbId: Int) {
self.id = Self.makeId(verbId: verbId)
self.verbId = verbId
}
static func makeId(verbId: Int) -> String {
"verb-\(verbId)"
}
}
@@ -0,0 +1,39 @@
import Foundation
import SharedModels
import SwiftData
/// SRS rating for verb-level vocab practice. Mirrors `CourseReviewStore` but
/// keyed by `verbId` (the integer primary key on `Verb`).
struct VerbReviewStore {
let context: ModelContext
@discardableResult
func fetchOrCreateReviewCard(verbId: Int) -> VerbReviewCard {
let id = VerbReviewCard.makeId(verbId: verbId)
let descriptor = FetchDescriptor<VerbReviewCard>(
predicate: #Predicate<VerbReviewCard> { $0.id == id }
)
if let existing = (try? context.fetch(descriptor))?.first {
return existing
}
let card = VerbReviewCard(verbId: verbId)
context.insert(card)
return card
}
func rate(verbId: Int, quality: ReviewQuality) {
let card = fetchOrCreateReviewCard(verbId: verbId)
let result = SRSEngine.review(
quality: quality,
currentEase: card.easeFactor,
currentInterval: card.interval,
currentReps: card.repetitions
)
card.easeFactor = result.easeFactor
card.interval = result.interval
card.repetitions = result.repetitions
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
card.lastReviewDate = Date()
try? context.save()
}
}
@@ -383,13 +383,24 @@ struct PracticeView: View {
private var vocabSection: some View { private var vocabSection: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
// NEW: Vocab Practice entry (flashcard + MC) // Vocab Flashcards (verb pool, filtered by Settings levels)
NavigationLink { NavigationLink {
VocabPracticeEntryView() VocabFlashcardPracticeView()
} label: { } label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple, practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
title: "Vocab Practice", title: "Vocab Flashcards",
subtitle: "Flashcards or multiple choice, pick a deck") subtitle: "Verb meaning → infinitive recall")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Vocab Multiple Choice (same verb pool)
NavigationLink {
VocabMultipleChoicePracticeView()
} label: {
practiceRowLabel(icon: "checklist", color: .purple,
title: "Vocab Multiple Choice",
subtitle: "Pick the Spanish infinitive from 4 options")
} }
.tint(.primary) .tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
@@ -2,44 +2,38 @@ import SwiftUI
import SharedModels import SharedModels
import SwiftData import SwiftData
/// English-first flashcard. Front shows the English meaning; tap to reveal /// English-first verb flashcard. Pool = all verbs whose `level` is enabled
/// the Spanish word with example sentences, an AI-generated illustrative /// in Settings (UserProgress.selectedVerbLevels) the same filter that
/// image, and SRS rating buttons. /// drives the conjugation practice modes.
///
/// 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.
struct VocabFlashcardPracticeView: View { struct VocabFlashcardPracticeView: View {
/// nil = all decks
let deckId: String?
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VocabImageService.self) private var imageService @Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = [] @State private var verbs: [Verb] = []
@State private var deckLookup: [String: CourseDeck] = [:]
@State private var index: Int = 0 @State private var index: Int = 0
@State private var revealed: Bool = false @State private var revealed: Bool = false
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingExampleForVerbId: Int? = nil
private var cloudContext: ModelContext { cloudModelContextProvider() } private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentCard: VocabCard? { private var currentVerb: Verb? {
guard index < cards.count else { return nil } guard index < verbs.count else { return nil }
return cards[index] return verbs[index]
}
private var sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])? {
guard let card = currentCard else { return nil }
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
let english = isReversed ? card.front : card.back
let spanish = isReversed ? card.back : card.front
return (english, spanish, card.examplesES, card.examplesEN)
} }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 24) {
progressBar progressBar
if let sides { if let verb = currentVerb {
cardBody(sides) cardBody(verb)
} else { } else {
completionView completionView
} }
@@ -58,26 +52,29 @@ struct VocabFlashcardPracticeView: View {
private var progressBar: some View { private var progressBar: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
ProgressView(value: cards.isEmpty ? 0 : Double(index) / Double(cards.count)) ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
.tint(.purple) .tint(.purple)
Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)") Text(verbs.isEmpty ? noPoolMessage : "\(min(index + 1, verbs.count)) of \(verbs.count)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
private var noPoolMessage: String {
"No verbs match the levels enabled in Settings"
}
// MARK: - Card // MARK: - Card
@ViewBuilder @ViewBuilder
private func cardBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { private func cardBody(_ verb: Verb) -> some View {
// Front (always visible) Text("to \(verb.english)")
Text(sides.english)
.font(.largeTitle.weight(.bold)) .font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 12) .padding(.top, 12)
if revealed { if revealed {
revealedContent(sides) revealedContent(verb)
} else { } else {
tapToReveal tapToReveal
} }
@@ -101,34 +98,43 @@ struct VocabFlashcardPracticeView: View {
} }
} }
private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { private func revealedContent(_ verb: Verb) -> some View {
VStack(spacing: 18) { VStack(spacing: 18) {
Text(sides.spanish) Text(verb.infinitive)
.font(.title.weight(.semibold)) .font(.title.weight(.semibold))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
if let card = currentCard { VerbIllustration(verb: verb)
VocabIllustration(card: card, deckLookup: deckLookup)
}
if !sides.examplesES.isEmpty { exampleBlock(for: verb)
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(zip(sides.examplesES, sides.examplesEN).enumerated()), id: \.offset) { _, pair in
VStack(alignment: .leading, spacing: 2) {
Text(pair.0).font(.subheadline).italic()
Text(pair.1).font(.caption).foregroundStyle(.secondary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
ratingButtons ratingButtons
} }
} }
@ViewBuilder
private func exampleBlock(for verb: Verb) -> some View {
if let example = exampleByVerbId[verb.id] {
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish).font(.subheadline).italic()
Text(example.english).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingExampleForVerbId == verb.id {
HStack(spacing: 8) {
ProgressView()
Text("Generating example…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
private var ratingButtons: some View { private var ratingButtons: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Text("How well did you know it?") Text("How well did you know it?")
@@ -165,7 +171,7 @@ struct VocabFlashcardPracticeView: View {
.foregroundStyle(.green) .foregroundStyle(.green)
Text("Session Complete") Text("Session Complete")
.font(.title2.bold()) .font(.title2.bold())
Text("\(cards.count) cards reviewed") Text("\(verbs.count) verbs reviewed")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Button("Done") { dismiss() } Button("Done") { dismiss() }
@@ -179,59 +185,99 @@ struct VocabFlashcardPracticeView: View {
// MARK: - Logic // MARK: - Logic
private func loadIfNeeded() { private func loadIfNeeded() {
guard cards.isEmpty else { return } guard verbs.isEmpty else { return }
let pool = fetchPool() verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
cards = pool.shuffled() primeExampleForCurrent()
deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) })
} }
private func fetchPool() -> [VocabCard] { private func primeExampleForCurrent() {
var descriptor = FetchDescriptor<VocabCard>() guard let verb = currentVerb else { return }
if let deckId { if exampleByVerbId[verb.id] != nil { return }
descriptor.predicate = #Predicate<VocabCard> { $0.deckId == deckId }
// 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
let infinitive = verb.infinitive
let english = verb.english
Task {
do {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: ["ind_presente"]
)
if let first = examples.first {
exampleCache.setExamples(examples, for: verbId)
if currentVerb?.id == verbId {
exampleByVerbId[verbId] = first
}
}
} catch {
// Silent the example block just stays hidden.
}
if generatingExampleForVerbId == verbId {
generatingExampleForVerbId = nil
}
} }
return (try? localContext.fetch(descriptor)) ?? []
}
private func fetchDecks() -> [CourseDeck] {
(try? localContext.fetch(FetchDescriptor<CourseDeck>())) ?? []
} }
private func rateAndAdvance(_ quality: ReviewQuality) { private func rateAndAdvance(_ quality: ReviewQuality) {
guard let card = currentCard else { return } guard let verb = currentVerb else { return }
CourseReviewStore(context: cloudContext).rate(card: card, quality: quality) VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
withAnimation(.smooth) { withAnimation(.smooth) {
revealed = false revealed = false
index += 1 index += 1
} }
primeExampleForCurrent()
} }
} }
// MARK: - Illustration (shared) // MARK: - Pool helper
/// AI-generated image for the card's English concept. Generates on first /// 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()
}
}
// MARK: - Verb illustration
/// AI-generated image for the verb's English concept. Generates on first
/// reveal and caches to disk. Falls back to a styled SF Symbol when Image /// reveal and caches to disk. Falls back to a styled SF Symbol when Image
/// Playground is unavailable. /// Playground is unavailable.
struct VocabIllustration: View { struct VerbIllustration: View {
let card: VocabCard let verb: Verb
let deckLookup: [String: CourseDeck]
@Environment(VocabImageService.self) private var service @Environment(VocabImageService.self) private var service
@State private var image: UIImage? @State private var image: UIImage?
@State private var isGenerating: Bool = false @State private var isGenerating: Bool = false
private var englishConcept: String {
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
return isReversed ? card.front : card.back
}
private var spanish: String {
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
return isReversed ? card.back : card.front
}
private var cacheKey: String { private var cacheKey: String {
VocabImageService.cacheKey(deckId: card.deckId, spanish: spanish, english: englishConcept) VocabImageService.cacheKey(
deckId: "verb",
spanish: verb.infinitive,
english: verb.english
)
} }
var body: some View { var body: some View {
@@ -284,7 +330,7 @@ struct VocabIllustration: View {
} }
guard VocabImageService.isAvailable else { return } guard VocabImageService.isAvailable else { return }
isGenerating = true isGenerating = true
let result = await service.image(forKey: cacheKey, concept: englishConcept) let result = await service.image(forKey: cacheKey, concept: "to \(verb.english)")
isGenerating = false isGenerating = false
if let result { if let result {
image = result image = result
@@ -2,46 +2,37 @@ import SwiftUI
import SharedModels import SharedModels
import SwiftData import SwiftData
/// English-first multiple choice. Prompt shows the English meaning; the user /// English-first verb multiple choice. Pool = verbs whose `level` is enabled
/// picks the correct Spanish word from 4 options (3 distractors drawn from the /// in Settings (UserProgress.selectedVerbLevels). 4 options shown, 1 correct
/// same deck, preferring matching part-of-speech via DictionaryService). /// + 3 random distractors from the same pool. After answer: reveal correct/
/// After answer: reveal correct/incorrect, show examples + image, rate SRS. /// incorrect, the verb infinitive, an AI illustration, an example sentence,
/// and SRS rating buttons.
struct VocabMultipleChoicePracticeView: View { struct VocabMultipleChoicePracticeView: View {
let deckId: String?
@Environment(\.modelContext) private var localContext @Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(DictionaryService.self) private var dictionary @Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = [] @State private var verbs: [Verb] = []
@State private var distractorPool: [VocabCard] = []
@State private var deckLookup: [String: CourseDeck] = [:]
@State private var index: Int = 0 @State private var index: Int = 0
@State private var options: [VocabCard] = [] @State private var options: [Verb] = []
@State private var selectedOption: VocabCard? = nil @State private var selectedOption: Verb? = nil
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingExampleForVerbId: Int? = nil
private var cloudContext: ModelContext { cloudModelContextProvider() } private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentCard: VocabCard? { private var currentVerb: Verb? {
guard index < cards.count else { return nil } guard index < verbs.count else { return nil }
return cards[index] return verbs[index]
}
private var sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])? {
guard let card = currentCard else { return nil }
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
let english = isReversed ? card.front : card.back
let spanish = isReversed ? card.back : card.front
return (english, spanish, card.examplesES, card.examplesEN)
} }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 22) { VStack(spacing: 22) {
progressBar progressBar
if let sides { if let verb = currentVerb {
questionBody(sides) questionBody(verb)
} else { } else {
completionView completionView
} }
@@ -60,9 +51,9 @@ struct VocabMultipleChoicePracticeView: View {
private var progressBar: some View { private var progressBar: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
ProgressView(value: cards.isEmpty ? 0 : Double(index) / Double(cards.count)) ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
.tint(.purple) .tint(.purple)
Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)") Text(verbs.isEmpty ? "No verbs match the levels enabled in Settings" : "\(min(index + 1, verbs.count)) of \(verbs.count)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -71,8 +62,8 @@ struct VocabMultipleChoicePracticeView: View {
// MARK: - Question // MARK: - Question
@ViewBuilder @ViewBuilder
private func questionBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { private func questionBody(_ verb: Verb) -> some View {
Text(sides.english) Text("to \(verb.english)")
.font(.largeTitle.weight(.bold)) .font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, 12) .padding(.top, 12)
@@ -80,7 +71,7 @@ struct VocabMultipleChoicePracticeView: View {
if selectedOption == nil { if selectedOption == nil {
optionGrid optionGrid
} else { } else {
revealedContent(sides) revealedContent(verb)
} }
} }
@@ -90,7 +81,7 @@ struct VocabMultipleChoicePracticeView: View {
Button { Button {
selectedOption = option selectedOption = option
} label: { } label: {
Text(spanishSide(of: option)) Text(option.infinitive)
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 16) .padding(.vertical, 16)
@@ -101,34 +92,17 @@ struct VocabMultipleChoicePracticeView: View {
} }
} }
private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { private func revealedContent(_ verb: Verb) -> some View {
VStack(spacing: 16) { VStack(spacing: 16) {
answerFeedback(sides) answerFeedback(verb)
VerbIllustration(verb: verb)
if let card = currentCard { exampleBlock(for: verb)
VocabIllustration(card: card, deckLookup: deckLookup)
}
if !sides.examplesES.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(zip(sides.examplesES, sides.examplesEN).enumerated()), id: \.offset) { _, pair in
VStack(alignment: .leading, spacing: 2) {
Text(pair.0).font(.subheadline).italic()
Text(pair.1).font(.caption).foregroundStyle(.secondary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
ratingButtons ratingButtons
} }
} }
private func answerFeedback(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View { private func answerFeedback(_ verb: Verb) -> some View {
let correct = (selectedOption?.id == currentCard?.id) let correct = (selectedOption?.id == verb.id)
return VStack(spacing: 6) { return VStack(spacing: 6) {
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill") Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 36)) .font(.system(size: 36))
@@ -136,12 +110,35 @@ struct VocabMultipleChoicePracticeView: View {
Text(correct ? "Correct!" : "Not quite") Text(correct ? "Correct!" : "Not quite")
.font(.headline) .font(.headline)
.foregroundStyle(correct ? .green : .red) .foregroundStyle(correct ? .green : .red)
Text(sides.spanish) Text(verb.infinitive)
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
.padding(.top, 4) .padding(.top, 4)
} }
} }
@ViewBuilder
private func exampleBlock(for verb: Verb) -> some View {
if let example = exampleByVerbId[verb.id] {
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish).font(.subheadline).italic()
Text(example.english).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingExampleForVerbId == verb.id {
HStack(spacing: 8) {
ProgressView()
Text("Generating example…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
private var ratingButtons: some View { private var ratingButtons: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Text("How well did you know it?") Text("How well did you know it?")
@@ -178,7 +175,7 @@ struct VocabMultipleChoicePracticeView: View {
.foregroundStyle(.green) .foregroundStyle(.green)
Text("Session Complete") Text("Session Complete")
.font(.title2.bold()) .font(.title2.bold())
Text("\(cards.count) cards reviewed") Text("\(verbs.count) verbs reviewed")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Button("Done") { dismiss() } Button("Done") { dismiss() }
@@ -192,71 +189,57 @@ struct VocabMultipleChoicePracticeView: View {
// MARK: - Logic // MARK: - Logic
private func loadIfNeeded() { private func loadIfNeeded() {
guard cards.isEmpty else { return } guard verbs.isEmpty else { return }
let pool = fetchPool() verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
cards = pool.shuffled()
distractorPool = pool
deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) })
if cards.count < 4 {
distractorPool = fetchAllCards()
}
prepareOptions() prepareOptions()
} primeExampleForCurrent()
private func fetchPool() -> [VocabCard] {
var descriptor = FetchDescriptor<VocabCard>()
if let deckId {
descriptor.predicate = #Predicate<VocabCard> { $0.deckId == deckId }
}
return (try? localContext.fetch(descriptor)) ?? []
}
private func fetchAllCards() -> [VocabCard] {
(try? localContext.fetch(FetchDescriptor<VocabCard>())) ?? []
}
private func fetchDecks() -> [CourseDeck] {
(try? localContext.fetch(FetchDescriptor<CourseDeck>())) ?? []
} }
private func prepareOptions() { private func prepareOptions() {
guard let card = currentCard else { options = []; return } guard let verb = currentVerb else { options = []; return }
let correctPOS = partOfSpeech(for: card) let candidates = verbs.filter { $0.id != verb.id }
let candidates = distractorPool.filter { $0.id != card.id } let distractors = Array(candidates.shuffled().prefix(3))
options = ([verb] + distractors).shuffled()
}
let posMatches = correctPOS.flatMap { pos in private func primeExampleForCurrent() {
candidates.filter { partOfSpeech(for: $0) == pos } guard let verb = currentVerb else { return }
} ?? [] if exampleByVerbId[verb.id] != nil { return }
let pickedDistractors: [VocabCard] if let cached = exampleCache.examples(for: verb.id)?.first {
if posMatches.count >= 3 { exampleByVerbId[verb.id] = cached
pickedDistractors = Array(posMatches.shuffled().prefix(3)) return
} else { }
// Fill with random others guard VerbExampleGenerator.isAvailable else { return }
var pool = posMatches generatingExampleForVerbId = verb.id
let remaining = candidates.filter { c in !pool.contains(where: { $0.id == c.id }) } let verbId = verb.id
pool.append(contentsOf: remaining.shuffled()) let infinitive = verb.infinitive
pickedDistractors = Array(pool.prefix(3)) let english = verb.english
Task {
do {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: ["ind_presente"]
)
if let first = examples.first {
exampleCache.setExamples(examples, for: verbId)
if currentVerb?.id == verbId {
exampleByVerbId[verbId] = first
}
}
} catch {}
if generatingExampleForVerbId == verbId {
generatingExampleForVerbId = nil
}
} }
options = ([card] + pickedDistractors).shuffled()
}
private func partOfSpeech(for card: VocabCard) -> String? {
let spanish = spanishSide(of: card).lowercased()
.trimmingCharacters(in: .punctuationCharacters)
.trimmingCharacters(in: .whitespaces)
return dictionary.lookup(spanish)?.partOfSpeech
}
private func spanishSide(of card: VocabCard) -> String {
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
return isReversed ? card.back : card.front
} }
private func rateAndAdvance(_ quality: ReviewQuality) { private func rateAndAdvance(_ quality: ReviewQuality) {
guard let card = currentCard else { return } guard let verb = currentVerb else { return }
CourseReviewStore(context: cloudContext).rate(card: card, quality: quality) VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
index += 1 index += 1
selectedOption = nil selectedOption = nil
prepareOptions() prepareOptions()
primeExampleForCurrent()
} }
} }
@@ -1,141 +0,0 @@
import SwiftUI
import SharedModels
import SwiftData
/// Entry screen for vocabulary practice. User picks a deck (or "All") and a
/// mode (Flashcard / Multiple Choice), then taps Start. The session view
/// fetches the pool, shuffles, and runs the cards.
struct VocabPracticeEntryView: View {
enum Mode: String, CaseIterable, Identifiable {
case flashcard
case multipleChoice
var id: String { rawValue }
var label: String {
switch self {
case .flashcard: return "Flashcard"
case .multipleChoice: return "Multiple Choice"
}
}
var systemImage: String {
switch self {
case .flashcard: return "rectangle.on.rectangle"
case .multipleChoice: return "checklist"
}
}
}
@Query(sort: [SortDescriptor(\CourseDeck.courseName), SortDescriptor(\CourseDeck.weekNumber)])
private var decks: [CourseDeck]
@AppStorage("vocabPracticeLastDeckId") private var lastDeckId: String = ""
@AppStorage("vocabPracticeLastMode") private var lastModeRaw: String = Mode.flashcard.rawValue
@State private var selectedDeckId: String? = nil
@State private var mode: Mode = .flashcard
@State private var startedSession: SessionConfig? = nil
/// Sentinel for "All decks" in the picker.
private let allDecksTag = "__all__"
private struct SessionConfig: Hashable {
let deckId: String? // nil = all decks
let mode: Mode
}
var body: some View {
Form {
Section("Mode") {
Picker("Mode", selection: $mode) {
ForEach(Mode.allCases) { m in
Label(m.label, systemImage: m.systemImage).tag(m)
}
}
.pickerStyle(.segmented)
}
Section("Deck") {
if decks.isEmpty {
Text("No course decks available yet.")
.foregroundStyle(.secondary)
} else {
Picker("Deck", selection: Binding(
get: { selectedDeckId ?? allDecksTag },
set: { selectedDeckId = ($0 == allDecksTag ? nil : $0) }
)) {
Text("All decks").tag(allDecksTag)
ForEach(grouped, id: \.course) { group in
Section(group.course) {
ForEach(group.decks) { deck in
Text(deckLabel(deck)).tag(deck.id)
}
}
}
}
.pickerStyle(.navigationLink)
}
}
Section {
Button {
persistChoice()
startedSession = SessionConfig(deckId: selectedDeckId, mode: mode)
} label: {
Label("Start", systemImage: "play.fill")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
.disabled(decks.isEmpty)
}
}
.navigationTitle("Vocab Practice")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(item: $startedSession) { config in
switch config.mode {
case .flashcard:
VocabFlashcardPracticeView(deckId: config.deckId)
case .multipleChoice:
VocabMultipleChoicePracticeView(deckId: config.deckId)
}
}
.onAppear(perform: restoreChoice)
}
// MARK: - Helpers
private struct DeckGroup {
let course: String
let decks: [CourseDeck]
}
private var grouped: [DeckGroup] {
let byCourse = Dictionary(grouping: decks, by: \.courseName)
return byCourse.keys.sorted().map { name in
DeckGroup(course: name, decks: (byCourse[name] ?? []).sorted {
if $0.weekNumber != $1.weekNumber { return $0.weekNumber < $1.weekNumber }
return $0.title < $1.title
})
}
}
private func deckLabel(_ deck: CourseDeck) -> String {
"W\(deck.weekNumber)\(deck.title)"
}
private func restoreChoice() {
if !lastDeckId.isEmpty, decks.contains(where: { $0.id == lastDeckId }) {
selectedDeckId = lastDeckId
}
if let m = Mode(rawValue: lastModeRaw) {
mode = m
}
}
private func persistChoice() {
lastDeckId = selectedDeckId ?? ""
lastModeRaw = mode.rawValue
}
}