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

122 lines
4.4 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
struct TextbookChapterListView: View {
let courseName: String
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query(sort: \TextbookChapter.number) private var allChapters: [TextbookChapter]
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
@State private var attempts: [TextbookExerciseAttempt] = []
private var chapters: [TextbookChapter] {
allChapters.filter { $0.courseName == courseName }
}
private var byPart: [(part: Int, chapters: [TextbookChapter])] {
let grouped = Dictionary(grouping: chapters, by: \.part)
return grouped.keys.sorted().map { p in
(p, grouped[p]!.sorted { $0.number < $1.number })
}
}
private func progressFor(_ chapter: TextbookChapter) -> (correct: Int, total: Int) {
let chNum = chapter.number
let chAttempts = attempts.filter {
$0.courseName == courseName && $0.chapterNumber == chNum
}
let total = chAttempts.reduce(0) { $0 + $1.totalCount }
let correct = chAttempts.reduce(0) { $0 + $1.correctCount + $1.closeCount }
return (correct, total)
}
var body: some View {
List {
if chapters.isEmpty {
ContentUnavailableView(
"Textbook loading",
systemImage: "book.closed",
description: Text("Textbook content is being prepared…")
)
} else {
ForEach(byPart, id: \.part) { part, partChapters in
Section {
ForEach(partChapters, id: \.id) { chapter in
NavigationLink(value: chapter) {
chapterRow(chapter)
}
.accessibilityIdentifier("textbook-chapter-row-\(chapter.number)")
}
} header: {
if part > 0 {
Text("Part \(part)")
} else {
Text("Chapters")
}
}
}
}
}
.navigationTitle("Textbook")
.onAppear(perform: loadAttempts)
}
@ViewBuilder
private func chapterRow(_ chapter: TextbookChapter) -> some View {
let p = progressFor(chapter)
HStack(alignment: .center, spacing: 12) {
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: 3)
.frame(width: 36, height: 36)
if p.total > 0 {
Circle()
.trim(from: 0, to: CGFloat(p.correct) / CGFloat(p.total))
.stroke(.orange, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 36, height: 36)
.rotationEffect(.degrees(-90))
}
Text("\(chapter.number)")
.font(.footnote.weight(.bold))
}
VStack(alignment: .leading, spacing: 2) {
Text(chapter.title)
.font(.headline)
HStack(spacing: 10) {
if chapter.exerciseCount > 0 {
Label("\(chapter.exerciseCount)", systemImage: "pencil.and.list.clipboard")
.font(.caption)
.foregroundStyle(.secondary)
}
if chapter.vocabTableCount > 0 {
Label("\(chapter.vocabTableCount)", systemImage: "list.bullet.rectangle")
.font(.caption)
.foregroundStyle(.secondary)
}
if p.total > 0 {
Text("\(p.correct)/\(p.total)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
Spacer()
}
.padding(.vertical, 4)
}
private func loadAttempts() {
attempts = (try? cloudModelContext.fetch(FetchDescriptor<TextbookExerciseAttempt>())) ?? []
}
}
#Preview {
NavigationStack {
TextbookChapterListView(courseName: "Complete Spanish Step-by-Step")
}
.modelContainer(for: [TextbookChapter.self], inMemory: true)
}