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

361 lines
13 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
import PencilKit
/// Interactive fill-in-the-blank view for one textbook exercise.
/// Supports keyboard typing OR Apple Pencil handwriting input per prompt.
struct TextbookExerciseView: View {
let chapter: TextbookChapter
let blockIndex: Int
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var answers: [Int: String] = [:]
@State private var drawings: [Int: PKDrawing] = [:]
@State private var grades: [Int: TextbookGrade] = [:]
@State private var inputMode: InputMode = .keyboard
@State private var activePencilPromptNumber: Int?
@State private var isRecognizing = false
@State private var isChecked = false
@State private var recognizedTextForActive: String = ""
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
enum InputMode: String {
case keyboard
case pencil
}
private var block: TextbookBlock? {
chapter.blocks().first { $0.index == blockIndex }
}
private var answerByNumber: [Int: TextbookAnswerItem] {
guard let items = block?.answerItems else { return [:] }
var out: [Int: TextbookAnswerItem] = [:]
for it in items {
out[it.number] = it
}
return out
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let b = block {
headerView(b)
inputModePicker
exerciseBody(b)
checkButton(b)
} else {
ContentUnavailableView(
"Exercise not found",
systemImage: "questionmark.circle"
)
}
}
.padding()
}
.navigationTitle("Exercise \(block?.exerciseId ?? "")")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadPreviousAttempt)
}
private func headerView(_ b: TextbookBlock) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Chapter \(chapter.number): \(chapter.title)")
.font(.caption)
.foregroundStyle(.secondary)
Text("Exercise \(b.exerciseId ?? "")")
.font(.title2.bold())
if let inst = b.instruction, !inst.isEmpty {
Text(stripInlineEmphasis(inst))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let extra = b.extra, !extra.isEmpty {
ForEach(Array(extra.enumerated()), id: \.offset) { _, e in
Text(stripInlineEmphasis(e))
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
}
}
}
}
private var inputModePicker: some View {
Picker("Input mode", selection: $inputMode) {
Label("Keyboard", systemImage: "keyboard").tag(InputMode.keyboard)
Label("Pencil", systemImage: "pencil.tip").tag(InputMode.pencil)
}
.pickerStyle(.segmented)
}
private func exerciseBody(_ b: TextbookBlock) -> some View {
VStack(alignment: .leading, spacing: 14) {
if b.freeform == true {
VStack(alignment: .leading, spacing: 6) {
Label("Freeform exercise", systemImage: "text.bubble")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
Text("Answers will vary. Use this space to write your own responses; they won't be auto-checked.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
}
let rawPrompts = b.prompts ?? []
let prompts = rawPrompts.isEmpty ? synthesizedPrompts(b) : rawPrompts
if prompts.isEmpty && b.extra?.isEmpty == false {
Text("Fill in the blanks above; answers will be graded when you tap Check.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(Array(prompts.enumerated()), id: \.offset) { i, prompt in
promptRow(index: i, prompt: prompt, expected: answerByNumber[i + 1])
}
}
}
}
/// When the source exercise prompts were embedded in a bitmap (common in
/// this textbook), we have no text for each question only the answer
/// key. Synthesize numbered placeholders so the user still gets one input
/// field per answer.
private func synthesizedPrompts(_ b: TextbookBlock) -> [String] {
guard let items = b.answerItems, !items.isEmpty else { return [] }
return items.map { "\($0.number)." }
}
private func promptRow(index: Int, prompt: String, expected: TextbookAnswerItem?) -> some View {
let number = index + 1
let grade = grades[number]
return VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
if let grade {
Image(systemName: iconFor(grade))
.foregroundStyle(colorFor(grade))
.font(.title3)
.padding(.top, 2)
}
Text(stripInlineEmphasis(prompt))
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
switch inputMode {
case .keyboard:
TextField("Your answer", text: binding(for: number))
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.font(.body)
.disabled(isChecked)
case .pencil:
pencilRow(number: number)
}
if isChecked, let grade, grade != .correct, let expected {
HStack(spacing: 6) {
Text("Answer:")
.font(.caption.weight(.semibold))
Text(expected.answer)
.font(.caption)
if !expected.alternates.isEmpty {
Text("(also: \(expected.alternates.joined(separator: ", ")))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.foregroundStyle(colorFor(grade))
}
}
.padding(10)
.background(backgroundFor(grade), in: RoundedRectangle(cornerRadius: 8))
}
private func pencilRow(number: Int) -> some View {
VStack(alignment: .leading, spacing: 6) {
HandwritingCanvas(
drawing: bindingDrawing(for: number),
onDrawingChanged: { recognizePencil(for: number) }
)
.frame(height: 100)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.separator, lineWidth: 1))
HStack {
if let typed = answers[number], !typed.isEmpty {
Text("Recognized: \(typed)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Clear") {
drawings[number] = PKDrawing()
answers[number] = ""
}
.font(.caption)
.tint(.secondary)
}
}
}
private func checkButton(_ b: TextbookBlock) -> some View {
let hasAnyAnswer = answers.values.contains { !$0.isEmpty }
let disabled = b.freeform == true || (!isChecked && !hasAnyAnswer)
return Button {
if isChecked {
resetExercise()
} else {
checkAnswers(b)
}
} label: {
Text(isChecked ? "Try again" : "Check answers")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.disabled(disabled)
}
// MARK: - Actions
private func checkAnswers(_ b: TextbookBlock) {
guard let prompts = b.prompts else { return }
var newGrades: [Int: TextbookGrade] = [:]
var states: [TextbookPromptState] = []
for (i, _) in prompts.enumerated() {
let number = i + 1
let user = answers[number] ?? ""
let expected = answerByNumber[number]
let canonical = expected?.answer ?? ""
let alts = expected?.alternates ?? []
let grade: TextbookGrade
if canonical.isEmpty {
grade = .wrong
} else {
grade = AnswerChecker.grade(userText: user, canonical: canonical, alternates: alts)
}
newGrades[number] = grade
states.append(TextbookPromptState(number: number, userText: user, grade: grade))
}
grades = newGrades
isChecked = true
saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
}
private func resetExercise() {
answers.removeAll()
drawings.removeAll()
grades.removeAll()
isChecked = false
}
private func recognizePencil(for number: Int) {
guard let drawing = drawings[number], !drawing.strokes.isEmpty else { return }
isRecognizing = true
Task {
let result = await HandwritingRecognizer.recognize(drawing: drawing)
await MainActor.run {
answers[number] = result.text
isRecognizing = false
}
}
}
private func saveAttempt(states: [TextbookPromptState], exerciseId: String) {
let attemptId = TextbookExerciseAttempt.attemptId(
courseName: chapter.courseName,
exerciseId: exerciseId
)
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
)
let context = cloudModelContext
let existing = (try? context.fetch(descriptor))?.first
let attempt = existing ?? TextbookExerciseAttempt(
id: attemptId,
courseName: chapter.courseName,
chapterNumber: chapter.number,
exerciseId: exerciseId
)
if existing == nil { context.insert(attempt) }
attempt.lastAttemptAt = Date()
attempt.setPromptStates(states)
try? context.save()
}
private func loadPreviousAttempt() {
guard let b = block else { return }
let attemptId = TextbookExerciseAttempt.attemptId(
courseName: chapter.courseName,
exerciseId: b.exerciseId ?? ""
)
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
)
guard let attempt = (try? cloudModelContext.fetch(descriptor))?.first else { return }
for s in attempt.promptStates() {
answers[s.number] = s.userText
grades[s.number] = s.grade
}
isChecked = !grades.isEmpty
}
// MARK: - Bindings
private func binding(for number: Int) -> Binding<String> {
Binding(
get: { answers[number] ?? "" },
set: { answers[number] = $0 }
)
}
private func bindingDrawing(for number: Int) -> Binding<PKDrawing> {
Binding(
get: { drawings[number] ?? PKDrawing() },
set: { drawings[number] = $0 }
)
}
// MARK: - UI helpers
private func iconFor(_ grade: TextbookGrade) -> String {
switch grade {
case .correct: return "checkmark.circle.fill"
case .close: return "circle.lefthalf.filled"
case .wrong: return "xmark.circle.fill"
}
}
private func colorFor(_ grade: TextbookGrade) -> Color {
switch grade {
case .correct: return .green
case .close: return .orange
case .wrong: return .red
}
}
private func backgroundFor(_ grade: TextbookGrade?) -> Color {
guard let grade else { return Color.secondary.opacity(0.05) }
switch grade {
case .correct: return .green.opacity(0.12)
case .close: return .orange.opacity(0.12)
case .wrong: return .red.opacity(0.12)
}
}
private func stripInlineEmphasis(_ s: String) -> String {
s.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
}
}