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>
206 lines
8.2 KiB
Swift
206 lines
8.2 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
|
|
struct DeckStudyView: View {
|
|
let deck: CourseDeck
|
|
@Environment(\.modelContext) private var modelContext
|
|
@State private var isStudying = false
|
|
@State private var speechService = SpeechService()
|
|
@State private var deckCards: [VocabCard] = []
|
|
@State private var expandedConjugations: Set<String> = []
|
|
|
|
private var isStemChangingDeck: Bool {
|
|
deck.title.localizedCaseInsensitiveContains("stem changing")
|
|
}
|
|
|
|
var body: some View {
|
|
cardListView
|
|
.navigationTitle(deck.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear { loadCards() }
|
|
.fullScreenCover(isPresented: $isStudying) {
|
|
NavigationStack {
|
|
VocabFlashcardView(
|
|
cards: deckCards.shuffled(),
|
|
speechService: speechService,
|
|
onDone: { isStudying = false },
|
|
deckTitle: deck.title
|
|
)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") { isStudying = false }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reversed stem-change decks have `front` as English, so prefer the
|
|
/// Spanish side when the card is stored that way. Strip parenthetical
|
|
/// notes and the reflexive `-se` ending for verb-table lookup.
|
|
private func inferInfinitive(card: VocabCard) -> String {
|
|
let raw: String
|
|
if deck.isReversed {
|
|
raw = card.back
|
|
} else {
|
|
raw = card.front
|
|
}
|
|
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let paren = t.firstIndex(of: "(") {
|
|
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
if t.hasSuffix("se") && t.count > 4 { t = String(t.dropLast(2)) }
|
|
return t
|
|
}
|
|
|
|
private func loadCards() {
|
|
let deckId = deck.id
|
|
let descriptor = FetchDescriptor<VocabCard>(
|
|
predicate: #Predicate<VocabCard> { $0.deckId == deckId },
|
|
sortBy: [SortDescriptor(\VocabCard.front)]
|
|
)
|
|
deckCards = (try? modelContext.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
// MARK: - Card List
|
|
|
|
private var cardListView: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
// Header
|
|
VStack(spacing: 8) {
|
|
Text("Week \(deck.weekNumber)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(deck.title)
|
|
.font(.title2.weight(.bold))
|
|
|
|
HStack(spacing: 12) {
|
|
Label("\(deckCards.count) cards", systemImage: "rectangle.on.rectangle")
|
|
|
|
if deck.isReversed {
|
|
Label("EN → ES", systemImage: "arrow.right")
|
|
} else {
|
|
Label("ES → EN", systemImage: "arrow.right")
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 8)
|
|
|
|
// Study button
|
|
Button {
|
|
withAnimation { isStudying = true }
|
|
} label: {
|
|
Label("Study Flashcards", systemImage: "brain.head.profile")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.orange)
|
|
.padding(.horizontal)
|
|
|
|
// Card list
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(Array(deckCards.enumerated()), id: \.offset) { _, card in
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 10) {
|
|
Button {
|
|
speechService.speak(card.front)
|
|
} label: {
|
|
Image(systemName: "speaker.wave.2")
|
|
.font(.body)
|
|
.foregroundStyle(.blue)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.borderless)
|
|
|
|
Text(card.front)
|
|
.font(.body.weight(.medium))
|
|
|
|
Spacer()
|
|
|
|
Text(card.back)
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
|
|
// Stem-change conjugation toggle
|
|
if isStemChangingDeck {
|
|
let verb = inferInfinitive(card: card)
|
|
let isOpen = expandedConjugations.contains(verb)
|
|
Button {
|
|
withAnimation(.smooth) {
|
|
if isOpen {
|
|
expandedConjugations.remove(verb)
|
|
} else {
|
|
expandedConjugations.insert(verb)
|
|
}
|
|
}
|
|
} label: {
|
|
Label(
|
|
isOpen ? "Hide conjugation" : "Show conjugation",
|
|
systemImage: isOpen ? "chevron.up" : "chevron.down"
|
|
)
|
|
.font(.caption.weight(.medium))
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.tint(.blue)
|
|
.padding(.leading, 42)
|
|
|
|
if isOpen {
|
|
StemChangeConjugationView(infinitive: verb)
|
|
.padding(.leading, 42)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
|
|
// Example sentences
|
|
if !card.examplesES.isEmpty {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ForEach(Array(zip(card.examplesES, card.examplesEN).enumerated()), id: \.offset) { _, pair in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(pair.0)
|
|
.font(.caption)
|
|
.italic()
|
|
Text(pair.1)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.leading, 42)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
|
|
Divider()
|
|
.padding(.leading, 16)
|
|
}
|
|
}
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal)
|
|
}
|
|
.padding(.vertical)
|
|
.adaptiveContainer()
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
DeckStudyView(deck: CourseDeck(
|
|
id: "test", weekNumber: 1, title: "Greetings", cardCount: 5,
|
|
courseName: "Test", isReversed: false
|
|
))
|
|
}
|
|
.modelContainer(for: [CourseDeck.self, VocabCard.self], inMemory: true)
|
|
}
|