Files
Spanish/Conjuga/Conjuga/Views/Practice/Vocab/VocabMultipleChoicePracticeView.swift
T
Trey T 0b7d4a73ad Add Vocab Practice — English-first flashcards + multiple choice, with AI illustrations
Practice tab restructured into three sections:
- Conjugation: the 6 conjugation modes + the Common Tenses / Weak
  Verbs / Irregularity Drills focus buttons.
- Vocabulary: new "Vocab Practice" entry (flashcard or multiple
  choice, deck-picker on entry) + the existing Vocab Review.
- Reading: Stories, Books, Lyrics, Conversation, Listening, Cloze
  (moved here from the flat list).

VocabPracticeEntryView lets the user pick any course/textbook deck
(or "All decks") and a mode (Flashcard / Multiple Choice). Last-used
choice is remembered via @AppStorage.

VocabFlashcardPracticeView:
  Front shows the English meaning. Tap to reveal the Spanish word,
  example sentences from the card, an AI-generated illustration of
  the concept, and Again/Hard/Good/Easy rating buttons. SRS updates
  via the existing CourseReviewStore.rate() path.

VocabMultipleChoicePracticeView:
  English prompt, 4 Spanish options. Distractors come from the same
  deck and prefer matching part-of-speech (via
  DictionaryService.lookup) — falls back to random when POS is
  unknown or the deck has fewer than 4 cards. After answer: reveal
  correct/incorrect, the Spanish word, examples, illustration, and
  the same rating buttons.

VocabImageService wraps Apple Intelligence's ImageCreator
(iOS 18.2+) for on-device illustration generation. Caches PNG
results to disk under Caches/VocabImages keyed by
SHA256(deck+ES+EN). In-flight dedup keeps concurrent requests for
the same key sharing one task. Falls back to a placeholder UI when
Apple Intelligence isn't available (older devices / disabled in
Settings) — detected lazily on the first failed ImageCreator init.

EN-first direction is enforced regardless of the underlying deck's
isReversed flag, so the user sees the English-to-Spanish recall
direction they asked for even when practising a reversed course
deck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:02:02 -05:00

263 lines
9.4 KiB
Swift

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.
struct VocabMultipleChoicePracticeView: View {
let deckId: String?
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(DictionaryService.self) private var dictionary
@Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = []
@State private var distractorPool: [VocabCard] = []
@State private var deckLookup: [String: CourseDeck] = [:]
@State private var index: Int = 0
@State private var options: [VocabCard] = []
@State private var selectedOption: VocabCard? = 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)
}
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let sides {
questionBody(sides)
} 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: cards.isEmpty ? 0 : Double(index) / Double(cards.count))
.tint(.purple)
Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - Question
@ViewBuilder
private func questionBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
Text(sides.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(sides)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(spanishSide(of: option))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> 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))
}
ratingButtons
}
}
private func answerFeedback(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
let correct = (selectedOption?.id == currentCard?.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(sides.spanish)
.font(.title2.weight(.semibold))
.padding(.top, 4)
}
}
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("\(cards.count) cards reviewed")
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.tint(.purple)
.padding(.top, 12)
}
.padding(.top, 60)
}
// 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()
}
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>())) ?? []
}
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()
}
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)
index += 1
selectedOption = nil
prepareOptions()
}
}