Files
Spanish/Conjuga/Conjuga/Views/Course/StemChangeConjugationView.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

98 lines
3.4 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
/// Shows the present-tense conjugation of a verb (identified by infinitive),
/// with any irregular/stem-change spans highlighted. Designed to drop into
/// stem-changing verb flashcards so learners can see the conjugation in-place.
struct StemChangeConjugationView: View {
let infinitive: String
@Environment(\.modelContext) private var modelContext
@State private var rows: [ConjugationRow] = []
private static let personLabels = ["yo", "", "él/ella/Ud.", "nosotros", "vosotros", "ellos/ellas/Uds."]
private static let tenseId = "ind_presente"
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Present tense")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
}
if rows.isEmpty {
Text("Conjugation not available")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
} else {
VStack(spacing: 6) {
ForEach(rows) { row in
HStack(alignment: .firstTextBaseline) {
Text(row.person)
.font(.callout)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .leading)
IrregularHighlightText(
form: row.form,
spans: row.spans,
font: .callout.monospaced(),
showLabels: false
)
Spacer()
}
}
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.onAppear(perform: loadForms)
}
private func loadForms() {
// Find the verb by infinitive (lowercase exact match).
let normalized = infinitive.lowercased().trimmingCharacters(in: .whitespaces)
let verbDescriptor = FetchDescriptor<Verb>(
predicate: #Predicate<Verb> { $0.infinitive == normalized }
)
guard let verb = (try? modelContext.fetch(verbDescriptor))?.first else {
rows = []
return
}
let verbId = verb.id
let tenseId = Self.tenseId
let formDescriptor = FetchDescriptor<VerbForm>(
predicate: #Predicate<VerbForm> { $0.verbId == verbId && $0.tenseId == tenseId },
sortBy: [SortDescriptor(\VerbForm.personIndex)]
)
let forms = (try? modelContext.fetch(formDescriptor)) ?? []
rows = forms.map { f in
ConjugationRow(
id: f.personIndex,
person: Self.personLabels[safe: f.personIndex] ?? "",
form: f.form,
spans: f.spans ?? []
)
}
}
}
private struct ConjugationRow: Identifiable {
let id: Int
let person: String
let form: String
let spans: [IrregularSpan]
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}