Files
Spanish/Conjuga/Conjuga/Views/Course/DeckStudyView.swift
Trey T 63dfc5e41a Add textbook reader, exercise grading, stem-change toggle, extraction pipeline
Major changes:
- Textbook UI: chapter list, reader, and interactive exercise view (keyboard
  + Apple Pencil) surfaced under the Course tab. 30 chapters, 251 exercises.
- Stem-change conjugation toggle on Week 4 flashcard decks (E-IE, E-I, O-UE).
  Uses existing VerbForm + IrregularSpan data to render highlighted present
  tense conjugations inline.
- Deterministic on-device answer grader with partial credit (correct / close
  for accent-stripped or single-char-typo / wrong). 11 unit tests cover it.
- SharedModels: TextbookChapter (local), TextbookExerciseAttempt (cloud-
  synced), AnswerGrader helpers. Bumped schema.
- DataLoader: textbook seeder (version 8) + refresh helpers that preserve
  LanGo course decks when textbook data is re-seeded.
- Local extraction pipeline in Conjuga/Scripts/textbook/ — XHTML chapter
  parser, answer-key parser, macOS Vision image OCR + PDF page OCR, merger,
  NSSpellChecker validator, language-aware auto-fixer, and repair pass that
  re-pairs quarantined vocab rows using bounding-box coordinates.
- UI test target (ConjugaUITests) with three tests: end-to-end textbook
  flow, all-chapters screenshot audit, and stem-change toggle verification.

Generated textbook content (textbook_data.json, textbook_vocab.json) and
third-party source files are gitignored — re-run Scripts/textbook/run_pipeline.sh
locally to regenerate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:12:55 -05:00

206 lines
8.2 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
struct DeckStudyView: View {
let deck: CourseDeck
@Environment(\.modelContext) private var modelContext
@State private var isStudying = false
@State private var speechService = SpeechService()
@State private var deckCards: [VocabCard] = []
@State private var expandedConjugations: Set<String> = []
private var isStemChangingDeck: Bool {
deck.title.localizedCaseInsensitiveContains("stem changing")
}
var body: some View {
cardListView
.navigationTitle(deck.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear { loadCards() }
.fullScreenCover(isPresented: $isStudying) {
NavigationStack {
VocabFlashcardView(
cards: deckCards.shuffled(),
speechService: speechService,
onDone: { isStudying = false },
deckTitle: deck.title
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { isStudying = false }
}
}
}
}
}
/// Reversed stem-change decks have `front` as English, so prefer the
/// Spanish side when the card is stored that way. Strip parenthetical
/// notes and the reflexive `-se` ending for verb-table lookup.
private func inferInfinitive(card: VocabCard) -> String {
let raw: String
if deck.isReversed {
raw = card.back
} else {
raw = card.front
}
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let paren = t.firstIndex(of: "(") {
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
}
if t.hasSuffix("se") && t.count > 4 { t = String(t.dropLast(2)) }
return t
}
private func loadCards() {
let deckId = deck.id
let descriptor = FetchDescriptor<VocabCard>(
predicate: #Predicate<VocabCard> { $0.deckId == deckId },
sortBy: [SortDescriptor(\VocabCard.front)]
)
deckCards = (try? modelContext.fetch(descriptor)) ?? []
}
// MARK: - Card List
private var cardListView: some View {
ScrollView {
VStack(spacing: 16) {
// Header
VStack(spacing: 8) {
Text("Week \(deck.weekNumber)")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(deck.title)
.font(.title2.weight(.bold))
HStack(spacing: 12) {
Label("\(deckCards.count) cards", systemImage: "rectangle.on.rectangle")
if deck.isReversed {
Label("EN → ES", systemImage: "arrow.right")
} else {
Label("ES → EN", systemImage: "arrow.right")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
// Study button
Button {
withAnimation { isStudying = true }
} label: {
Label("Study Flashcards", systemImage: "brain.head.profile")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.padding(.horizontal)
// Card list
LazyVStack(spacing: 0) {
ForEach(Array(deckCards.enumerated()), id: \.offset) { _, card in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button {
speechService.speak(card.front)
} label: {
Image(systemName: "speaker.wave.2")
.font(.body)
.foregroundStyle(.blue)
.frame(width: 32, height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.borderless)
Text(card.front)
.font(.body.weight(.medium))
Spacer()
Text(card.back)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
// Stem-change conjugation toggle
if isStemChangingDeck {
let verb = inferInfinitive(card: card)
let isOpen = expandedConjugations.contains(verb)
Button {
withAnimation(.smooth) {
if isOpen {
expandedConjugations.remove(verb)
} else {
expandedConjugations.insert(verb)
}
}
} label: {
Label(
isOpen ? "Hide conjugation" : "Show conjugation",
systemImage: isOpen ? "chevron.up" : "chevron.down"
)
.font(.caption.weight(.medium))
}
.buttonStyle(.borderless)
.tint(.blue)
.padding(.leading, 42)
if isOpen {
StemChangeConjugationView(infinitive: verb)
.padding(.leading, 42)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
// Example sentences
if !card.examplesES.isEmpty {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(zip(card.examplesES, card.examplesEN).enumerated()), id: \.offset) { _, pair in
VStack(alignment: .leading, spacing: 2) {
Text(pair.0)
.font(.caption)
.italic()
Text(pair.1)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.leading, 42)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
.padding(.leading, 16)
}
}
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
.padding(.vertical)
.adaptiveContainer()
}
}
}
#Preview {
NavigationStack {
DeckStudyView(deck: CourseDeck(
id: "test", weekNumber: 1, title: "Greetings", cardCount: 5,
courseName: "Test", isReversed: false
))
}
.modelContainer(for: [CourseDeck.self, VocabCard.self], inMemory: true)
}