diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index ce094c0..5d12523 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = ""; }; A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = ""; }; A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = ""; }; A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = ""; }; @@ -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 = ""; }; D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = ""; }; - D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabPracticeEntryView.swift; sourceTree = ""; }; DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = ""; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = ""; }; @@ -369,6 +369,7 @@ 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */, A661ADF1141176EE96774138 /* BookSpeechController.swift */, 3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */, + A2ACC4C35491174257770941 /* VerbReviewStore.swift */, ); path = Services; sourceTree = ""; @@ -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; }; diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 0411ff7..a57b00e 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -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 diff --git a/Conjuga/Conjuga/Models/ReviewCard.swift b/Conjuga/Conjuga/Models/ReviewCard.swift index 9765c81..68c19b2 100644 --- a/Conjuga/Conjuga/Models/ReviewCard.swift +++ b/Conjuga/Conjuga/Models/ReviewCard.swift @@ -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)" + } +} diff --git a/Conjuga/Conjuga/Services/VerbReviewStore.swift b/Conjuga/Conjuga/Services/VerbReviewStore.swift new file mode 100644 index 0000000..8f7a7da --- /dev/null +++ b/Conjuga/Conjuga/Services/VerbReviewStore.swift @@ -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( + predicate: #Predicate { $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() + } +} diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index a369258..3c567f6 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -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)) diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift index dc3b9e4..04efcd5 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift @@ -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,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) { - Text(sides.spanish) + Text(verb.infinitive) .font(.title.weight(.semibold)) .multilineTextAlignment(.center) - if let card = currentCard { - VocabIllustration(card: card, deckLookup: deckLookup) - } + VerbIllustration(verb: verb) - 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)) - } + exampleBlock(for: verb) 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 { VStack(spacing: 10) { Text("How well did you know it?") @@ -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() - if let deckId { - descriptor.predicate = #Predicate { $0.deckId == deckId } + private func primeExampleForCurrent() { + guard let verb = currentVerb else { return } + if exampleByVerbId[verb.id] != nil { return } + + // Cache hit? + if let cached = exampleCache.examples(for: verb.id)?.first { + exampleByVerbId[verb.id] = cached + return + } + // Otherwise lazy-generate (no blocking on tap-to-reveal). + guard VerbExampleGenerator.isAvailable else { return } + generatingExampleForVerbId = verb.id + let verbId = verb.id + 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())) ?? [] } 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 diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift index 35c06c9..84abff3 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift @@ -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() - if let deckId { - descriptor.predicate = #Predicate { $0.deckId == deckId } - } - return (try? localContext.fetch(descriptor)) ?? [] - } - - private func fetchAllCards() -> [VocabCard] { - (try? localContext.fetch(FetchDescriptor())) ?? [] - } - - private func fetchDecks() -> [CourseDeck] { - (try? localContext.fetch(FetchDescriptor())) ?? [] + primeExampleForCurrent() } private func prepareOptions() { - guard let card = currentCard else { options = []; return } - let correctPOS = partOfSpeech(for: card) - let candidates = distractorPool.filter { $0.id != card.id } + 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() + } - 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)) + 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 + } } - 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) { - 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() } } diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabPracticeEntryView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabPracticeEntryView.swift deleted file mode 100644 index 2305557..0000000 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabPracticeEntryView.swift +++ /dev/null @@ -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 - } -}