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>
122 lines
4.4 KiB
Swift
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)
|
|
}
|