Files
Spanish/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift
T
Trey T c890095610 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>
2026-05-13 23:31:12 -05:00

246 lines
8.4 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
/// 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 {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@State private var verbs: [Verb] = []
@State private var index: Int = 0
@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 currentVerb: Verb? {
guard index < verbs.count else { return nil }
return verbs[index]
}
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let verb = currentVerb {
questionBody(verb)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Vocab Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: index)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
.tint(.purple)
Text(verbs.isEmpty ? "No verbs match the levels enabled in Settings" : "\(min(index + 1, verbs.count)) of \(verbs.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - Question
@ViewBuilder
private func questionBody(_ verb: Verb) -> some View {
Text("to \(verb.english)")
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(verb)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(option.infinitive)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ verb: Verb) -> some View {
VStack(spacing: 16) {
answerFeedback(verb)
VerbIllustration(verb: verb)
exampleBlock(for: verb)
ratingButtons
}
}
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))
.foregroundStyle(correct ? .green : .red)
Text(correct ? "Correct!" : "Not quite")
.font(.headline)
.foregroundStyle(correct ? .green : .red)
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?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rateAndAdvance(quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text("Session Complete")
.font(.title2.bold())
Text("\(verbs.count) verbs reviewed")
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.tint(.purple)
.padding(.top, 12)
}
.padding(.top, 60)
}
// MARK: - Logic
private func loadIfNeeded() {
guard verbs.isEmpty else { return }
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
prepareOptions()
primeExampleForCurrent()
}
private func prepareOptions() {
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 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 rateAndAdvance(_ quality: ReviewQuality) {
guard let verb = currentVerb else { return }
VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
index += 1
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
}