Files
Spanish/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift
T
Trey T dd08a09860 Vocab Practice — apply code-review fixes for the SRS session work
Addresses the lead-review findings on the new vocab SRS/persistence code:

- Resume a persisted study group only when every stored verb resolves,
  else rebuild fresh — a partial resume desynced learnedCount from a
  shrunken queue and then persisted the loss.
- studyAgain() clears the finished group before building a new one.
- VocabStudyGroupStore.persist() collapses duplicate group records
  (cross-device sync races) down to the newest.
- Learn mode now derives its verb list from the live quiz queue, so it
  browses exactly what's left to learn instead of a stale frozen pool.
- VerbExampleGenerator retries when the first batch throws, not only
  when it returns bad data.
- Per-verb example generation tracks in-flight verbs in a Set, so the
  loading spinner clears reliably and duplicate generations are skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:59:33 -05:00

297 lines
10 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options
/// (1 correct + 3 random distractors from the session pool). After answering:
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
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 session: VocabSessionQueue?
@State private var distractorPool: [Verb] = []
@State private var options: [Verb] = []
@State private var selectedOption: Verb? = nil
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingVerbIds: Set<Int> = []
@State private var speech = SpeechService()
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentVerb: Verb? { session?.current?.verb }
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: currentVerb?.id)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
Text(progressLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Question
@ViewBuilder
private func questionBody(_ verb: Verb) -> some View {
Text(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)
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)
HStack(spacing: 10) {
Text(verb.infinitive)
.font(.title2.weight(.semibold))
Button {
speech.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.body)
.padding(8)
}
.glassEffect(in: .circle)
.accessibilityLabel("Say it out loud")
}
.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 generatingVerbIds.contains(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, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
Button {
answer(rating)
} 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?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button {
studyAgain()
} label: {
Label("Study Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
return "\(learned) verb\(learned == 1 ? "" : "s") learned"
}
return "No verbs are due right now. Study Again to review anyway."
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
distractorPool = verbs
session = VocabSessionQueue(verbs: verbs)
prepareOptions()
primeExampleForCurrent()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
private func prepareOptions() {
guard let verb = currentVerb else { options = []; return }
let candidates = distractorPool.filter { $0.id != verb.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([verb] + distractors).shuffled()
}
private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil
if let graduation {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
}
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
private func primeExampleForCurrent() {
guard let verb = currentVerb else { return }
let verbId = verb.id
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
if let cached = exampleCache.examples(for: verbId)?.first {
exampleByVerbId[verbId] = cached
return
}
guard VerbExampleGenerator.isAvailable else { return }
generatingVerbIds.insert(verbId)
let infinitive = verb.infinitive
let english = verb.english
let formsByTense = ReferenceStore(context: localContext)
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
Task {
do {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: VocabExampleTenseIds.canonical,
formsByTense: formsByTense
)
exampleCache.setExamples(examples, for: verbId)
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
if let pick, currentVerb?.id == verbId {
exampleByVerbId[verbId] = pick
}
} catch {}
generatingVerbIds.remove(verbId)
}
}
}