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>
361 lines
13 KiB
Swift
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: "")
|
|
}
|
|
}
|