import SwiftUI import SharedModels import SwiftData struct TextbookChapterView: View { let chapter: TextbookChapter @State private var expandedVocab: Set = [] private var blocks: [TextbookBlock] { chapter.blocks() } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { headerView Divider() ForEach(blocks) { block in blockView(block) } } .padding(.horizontal) .padding(.vertical, 12) } .navigationTitle(chapter.title) .navigationBarTitleDisplayMode(.inline) } private var headerView: some View { VStack(alignment: .leading, spacing: 4) { if chapter.part > 0 { Text("Part \(chapter.part)") .font(.subheadline) .foregroundStyle(.secondary) } Text("Chapter \(chapter.number)") .font(.subheadline) .foregroundStyle(.secondary) Text(chapter.title) .font(.largeTitle.bold()) } } @ViewBuilder private func blockView(_ block: TextbookBlock) -> some View { switch block.kind { case .heading: headingView(block) case .paragraph: paragraphView(block) case .keyVocabHeader: HStack(spacing: 6) { Image(systemName: "star.fill").foregroundStyle(.orange) Text("Key Vocabulary") .font(.headline) .foregroundStyle(.orange) } .padding(.top, 8) case .vocabTable: vocabTableView(block) case .exercise: exerciseLinkView(block) } } private func headingView(_ block: TextbookBlock) -> some View { let level = block.level ?? 3 let font: Font switch level { case 2: font = .title.bold() case 3: font = .title2.bold() case 4: font = .title3.weight(.semibold) default: font = .headline } return Text(stripInlineEmphasis(block.text ?? "")) .font(font) .padding(.top, 10) } private func paragraphView(_ block: TextbookBlock) -> some View { Text(attributedFromMarkdownish(block.text ?? "")) .font(.body) .fixedSize(horizontal: false, vertical: true) } private func vocabTableView(_ block: TextbookBlock) -> some View { let expanded = expandedVocab.contains(block.index) let cards = block.cards ?? [] let lines = block.ocrLines ?? [] let itemCount = cards.isEmpty ? lines.count : cards.count return VStack(alignment: .leading, spacing: 4) { Button { if expanded { expandedVocab.remove(block.index) } else { expandedVocab.insert(block.index) } } label: { HStack { Image(systemName: expanded ? "chevron.down" : "chevron.right") .font(.caption) Text("Vocabulary (\(itemCount) items)") .font(.subheadline.weight(.medium)) .foregroundStyle(.primary) Spacer() } .contentShape(Rectangle()) } .buttonStyle(.plain) if expanded { if cards.isEmpty { // Fallback: no paired cards available — show raw OCR lines. VStack(alignment: .leading, spacing: 2) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in Text(line) .font(.callout.monospaced()) .foregroundStyle(.secondary) } } .padding(.leading, 14) } else { vocabGrid(cards: cards) .padding(.leading, 14) } } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) } @ViewBuilder private func vocabGrid(cards: [TextbookVocabPair]) -> some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { ForEach(Array(cards.enumerated()), id: \.offset) { _, card in GridRow { Text(card.front) .font(.callout) .foregroundStyle(.primary) Text(card.back) .font(.callout) .foregroundStyle(.secondary) } } } } private func exerciseLinkView(_ block: TextbookBlock) -> some View { NavigationLink(value: TextbookExerciseDestination( chapterId: chapter.id, chapterNumber: chapter.number, blockIndex: block.index )) { HStack(spacing: 10) { Image(systemName: "pencil.and.list.clipboard") .foregroundStyle(.orange) .font(.title3) VStack(alignment: .leading, spacing: 2) { Text("Exercise \(block.exerciseId ?? "")") .font(.headline) if let inst = block.instruction, !inst.isEmpty { Text(stripInlineEmphasis(inst)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } Spacer() Image(systemName: "chevron.right") .foregroundStyle(.secondary) .font(.caption) } .padding(12) .background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) } // Strip our ad-hoc ** / * markers from parsed text private func stripInlineEmphasis(_ s: String) -> String { s.replacingOccurrences(of: "**", with: "") .replacingOccurrences(of: "*", with: "") } private func attributedFromMarkdownish(_ s: String) -> AttributedString { // Parser emits `**bold**` and `*italic*`. Try to render via AttributedString markdown. if let parsed = try? AttributedString(markdown: s, options: .init(allowsExtendedAttributes: true)) { return parsed } return AttributedString(stripInlineEmphasis(s)) } } struct TextbookExerciseDestination: Hashable { let chapterId: String let chapterNumber: Int let blockIndex: Int } #Preview { NavigationStack { TextbookChapterView(chapter: TextbookChapter( id: "ch1", number: 1, title: "Sample", part: 1, courseName: "Preview", bodyJSON: Data(), exerciseCount: 0, vocabTableCount: 0 )) } }