c890095610
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>
246 lines
8.4 KiB
Swift
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()
|
|
}
|
|
}
|