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 */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.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 */; };
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.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 */; };
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.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 */; };
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.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; };
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>"; };
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>"; };
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>"; };
@@ -254,7 +255,6 @@
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>"; };
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>"; };
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>"; };
@@ -369,6 +369,7 @@
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */,
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -376,7 +377,6 @@
1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
isa = PBXGroup;
children = (
D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */,
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
);
@@ -800,9 +800,9 @@
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */,
3F7C308425743919FC4407A8 /* VocabPracticeEntryView.swift in Sources */,
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+2 -2
View File
@@ -71,14 +71,14 @@ struct ConjugaApp: App {
let cloudConfig = ModelConfiguration(
"cloud",
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,
TextbookExerciseAttempt.self, ExtraStudyMark.self,
]),
cloudKitDatabase: .private("iCloud.com.conjuga.app")
)
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,
TextbookExerciseAttempt.self, ExtraStudyMark.self,
configurations: cloudConfig
+27
View File
@@ -55,3 +55,30 @@ final class CourseReviewCard {
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 {
VStack(spacing: 12) {
// NEW: Vocab Practice entry (flashcard + MC)
// Vocab Flashcards (verb pool, filtered by Settings levels)
NavigationLink {
VocabPracticeEntryView()
VocabFlashcardPracticeView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
title: "Vocab Practice",
subtitle: "Flashcards or multiple choice, pick a deck")
title: "Vocab Flashcards",
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)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
@@ -2,44 +2,38 @@ import SwiftUI
import SharedModels
import SwiftData
/// English-first flashcard. Front shows the English meaning; tap to reveal
/// the Spanish word with example sentences, an AI-generated illustrative
/// image, and SRS rating buttons.
/// English-first verb flashcard. Pool = all verbs whose `level` is enabled
/// in Settings (UserProgress.selectedVerbLevels) the same filter that
/// 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 {
/// nil = all decks
let deckId: String?
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VocabImageService.self) private var imageService
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = []
@State private var deckLookup: [String: CourseDeck] = [:]
@State private var verbs: [Verb] = []
@State private var index: Int = 0
@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 currentCard: VocabCard? {
guard index < cards.count else { return nil }
return cards[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)
private var currentVerb: Verb? {
guard index < verbs.count else { return nil }
return verbs[index]
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
progressBar
if let sides {
cardBody(sides)
if let verb = currentVerb {
cardBody(verb)
} else {
completionView
}
@@ -58,26 +52,29 @@ struct VocabFlashcardPracticeView: View {
private var progressBar: some View {
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)
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)
.foregroundStyle(.secondary)
}
}
private var noPoolMessage: String {
"No verbs match the levels enabled in Settings"
}
// MARK: - Card
@ViewBuilder
private func cardBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
// Front (always visible)
Text(sides.english)
private func cardBody(_ verb: Verb) -> some View {
Text("to \(verb.english)")
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
revealedContent(sides)
revealedContent(verb)
} else {
tapToReveal
}
@@ -101,31 +98,40 @@ 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) {
Text(sides.spanish)
Text(verb.infinitive)
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
if let card = currentCard {
VocabIllustration(card: card, deckLookup: deckLookup)
VerbIllustration(verb: verb)
exampleBlock(for: verb)
ratingButtons
}
}
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)
}
}
@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)
}
ratingButtons
.frame(maxWidth: .infinity)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
@@ -165,7 +171,7 @@ struct VocabFlashcardPracticeView: View {
.foregroundStyle(.green)
Text("Session Complete")
.font(.title2.bold())
Text("\(cards.count) cards reviewed")
Text("\(verbs.count) verbs reviewed")
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Done") { dismiss() }
@@ -179,59 +185,99 @@ struct VocabFlashcardPracticeView: View {
// MARK: - Logic
private func loadIfNeeded() {
guard cards.isEmpty else { return }
let pool = fetchPool()
cards = pool.shuffled()
deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) })
guard verbs.isEmpty else { return }
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
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 primeExampleForCurrent() {
guard let verb = currentVerb else { return }
if exampleByVerbId[verb.id] != nil { return }
private func fetchDecks() -> [CourseDeck] {
(try? localContext.fetch(FetchDescriptor<CourseDeck>())) ?? []
// 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
}
}
}
private func rateAndAdvance(_ quality: ReviewQuality) {
guard let card = currentCard else { return }
CourseReviewStore(context: cloudContext).rate(card: card, quality: quality)
guard let verb = currentVerb else { return }
VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
withAnimation(.smooth) {
revealed = false
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
/// Playground is unavailable.
struct VocabIllustration: View {
let card: VocabCard
let deckLookup: [String: CourseDeck]
struct VerbIllustration: View {
let verb: Verb
@Environment(VocabImageService.self) private var service
@State private var image: UIImage?
@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 {
VocabImageService.cacheKey(deckId: card.deckId, spanish: spanish, english: englishConcept)
VocabImageService.cacheKey(
deckId: "verb",
spanish: verb.infinitive,
english: verb.english
)
}
var body: some View {
@@ -284,7 +330,7 @@ struct VocabIllustration: View {
}
guard VocabImageService.isAvailable else { return }
isGenerating = true
let result = await service.image(forKey: cacheKey, concept: englishConcept)
let result = await service.image(forKey: cacheKey, concept: "to \(verb.english)")
isGenerating = false
if let result {
image = result
@@ -2,46 +2,37 @@ import SwiftUI
import SharedModels
import SwiftData
/// English-first multiple choice. Prompt shows the English meaning; the user
/// picks the correct Spanish word from 4 options (3 distractors drawn from the
/// same deck, preferring matching part-of-speech via DictionaryService).
/// After answer: reveal correct/incorrect, show examples + image, rate SRS.
/// 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.
struct VocabMultipleChoicePracticeView: View {
let deckId: String?
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(DictionaryService.self) private var dictionary
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = []
@State private var distractorPool: [VocabCard] = []
@State private var deckLookup: [String: CourseDeck] = [:]
@State private var verbs: [Verb] = []
@State private var index: Int = 0
@State private var options: [VocabCard] = []
@State private var selectedOption: VocabCard? = nil
@State private var options: [Verb] = []
@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 currentCard: VocabCard? {
guard index < cards.count else { return nil }
return cards[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)
private var currentVerb: Verb? {
guard index < verbs.count else { return nil }
return verbs[index]
}
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let sides {
questionBody(sides)
if let verb = currentVerb {
questionBody(verb)
} else {
completionView
}
@@ -60,9 +51,9 @@ struct VocabMultipleChoicePracticeView: View {
private var progressBar: some View {
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)
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)
.foregroundStyle(.secondary)
}
@@ -71,8 +62,8 @@ struct VocabMultipleChoicePracticeView: View {
// MARK: - Question
@ViewBuilder
private func questionBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
Text(sides.english)
private func questionBody(_ verb: Verb) -> some View {
Text("to \(verb.english)")
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
@@ -80,7 +71,7 @@ struct VocabMultipleChoicePracticeView: View {
if selectedOption == nil {
optionGrid
} else {
revealedContent(sides)
revealedContent(verb)
}
}
@@ -90,7 +81,7 @@ struct VocabMultipleChoicePracticeView: View {
Button {
selectedOption = option
} label: {
Text(spanishSide(of: option))
Text(option.infinitive)
.font(.headline)
.frame(maxWidth: .infinity)
.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) {
answerFeedback(sides)
if let card = currentCard {
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))
}
answerFeedback(verb)
VerbIllustration(verb: verb)
exampleBlock(for: verb)
ratingButtons
}
}
private func answerFeedback(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
let correct = (selectedOption?.id == currentCard?.id)
private func answerFeedback(_ verb: Verb) -> some View {
let correct = (selectedOption?.id == verb.id)
return VStack(spacing: 6) {
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 36))
@@ -136,12 +110,35 @@ struct VocabMultipleChoicePracticeView: View {
Text(correct ? "Correct!" : "Not quite")
.font(.headline)
.foregroundStyle(correct ? .green : .red)
Text(sides.spanish)
Text(verb.infinitive)
.font(.title2.weight(.semibold))
.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 {
VStack(spacing: 10) {
Text("How well did you know it?")
@@ -178,7 +175,7 @@ struct VocabMultipleChoicePracticeView: View {
.foregroundStyle(.green)
Text("Session Complete")
.font(.title2.bold())
Text("\(cards.count) cards reviewed")
Text("\(verbs.count) verbs reviewed")
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Done") { dismiss() }
@@ -192,71 +189,57 @@ struct VocabMultipleChoicePracticeView: View {
// MARK: - Logic
private func loadIfNeeded() {
guard cards.isEmpty else { return }
let pool = fetchPool()
cards = pool.shuffled()
distractorPool = pool
deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) })
if cards.count < 4 {
distractorPool = fetchAllCards()
}
guard verbs.isEmpty else { return }
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
prepareOptions()
}
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>())) ?? []
primeExampleForCurrent()
}
private func prepareOptions() {
guard let card = currentCard else { options = []; return }
let correctPOS = partOfSpeech(for: card)
let candidates = distractorPool.filter { $0.id != card.id }
let posMatches = correctPOS.flatMap { pos in
candidates.filter { partOfSpeech(for: $0) == pos }
} ?? []
let pickedDistractors: [VocabCard]
if posMatches.count >= 3 {
pickedDistractors = Array(posMatches.shuffled().prefix(3))
} else {
// Fill with random others
var pool = posMatches
let remaining = candidates.filter { c in !pool.contains(where: { $0.id == c.id }) }
pool.append(contentsOf: remaining.shuffled())
pickedDistractors = Array(pool.prefix(3))
}
options = ([card] + pickedDistractors).shuffled()
guard let verb = currentVerb else { options = []; return }
let candidates = verbs.filter { $0.id != verb.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([verb] + distractors).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 primeExampleForCurrent() {
guard let verb = currentVerb else { return }
if exampleByVerbId[verb.id] != nil { return }
if let cached = exampleCache.examples(for: verb.id)?.first {
exampleByVerbId[verb.id] = cached
return
}
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 {}
if generatingExampleForVerbId == verbId {
generatingExampleForVerbId = nil
}
}
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) {
guard let card = currentCard else { return }
CourseReviewStore(context: cloudContext).rate(card: card, quality: quality)
guard let verb = currentVerb else { return }
VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
index += 1
selectedOption = nil
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
}
}