0b7d4a73ad
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>
263 lines
9.4 KiB
Swift
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()
|
|
}
|
|
}
|