Files
Spanish/Conjuga/Conjuga/Views/Guide/GuideView.swift
Trey t 47a7871c38 Add 13 new grammar notes with 1010 exercises from video extraction
Scraped a 4h Spanish fundamentals YouTube video (transcript + OCR on
14810 frames), extracted structured content across 52 chapters, and
generated fill-in-the-blank quizzes for every grammar topic.

- 13 new GrammarNote entries (articles, possessives, demonstratives,
  greetings, poder, al/del, prepositional pronouns, irregular yo,
  stem-changing, stressed possessives, present/future perfect, present
  indicative conjugation)
- 1010 generated exercises across all 36 grammar notes (new + existing)
- Fix tense guide parser to handle unnumbered *Usages* blocks
- Rewrite 6 broken tense guide bodies (imperative, subj pluperfect,
  subj future) with numbered usage format
- Bump courseDataVersion 5→6 with TenseGuide refresh on upgrade
- Add docs/spanish-fundamentals/ with raw transcripts, polished notes,
  structured JSON, and exercise data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:40:05 -05:00

575 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import SwiftData
import SharedModels
struct GuideView: View {
@Environment(\.modelContext) private var modelContext
@State private var guides: [TenseGuide] = []
@State private var selectedGuide: TenseGuide?
@State private var selectedNote: GrammarNote?
@State private var selectedTab: GuideTab = .tenses
enum GuideTab: String, CaseIterable {
case tenses = "Tenses"
case grammar = "Grammar"
}
private var guideMap: [String: TenseGuide] {
Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
Picker("Guide", selection: $selectedTab) {
ForEach(GuideTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
switch selectedTab {
case .tenses:
tensesList
case .grammar:
grammarList
}
}
.navigationTitle("Guide")
.task { loadGuides() }
.onAppear(perform: loadGuides)
.onChange(of: selectedTab) { _, _ in
selectedGuide = nil
selectedNote = nil
}
} detail: {
if let guide = selectedGuide {
GuideDetailView(guide: guide)
} else if let note = selectedNote {
GrammarNoteDetailView(note: note)
} else {
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
}
}
}
private var tensesList: some View {
List(selection: $selectedGuide) {
ForEach(TenseInfo.byMood(), id: \.0) { mood, tenses in
Section(mood) {
ForEach(tenses) { tense in
if let guide = guideMap[tense.id] {
NavigationLink(value: guide) {
TenseRowView(tense: tense)
}
} else {
TenseRowView(tense: tense)
.foregroundStyle(.tertiary)
}
}
}
}
}
}
private var grammarList: some View {
GrammarNotesListView(selectedNote: $selectedNote)
}
private func loadGuides() {
// Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else {
print("[GuideView] ⚠️ SharedStore.localContainer is nil")
return
}
let context = ModelContext(container)
guides = ReferenceStore(context: context).fetchGuides()
print("[GuideView] loaded \(guides.count) tense guides (container: \(ObjectIdentifier(container)))")
if selectedGuide == nil {
selectedGuide = guides.first
}
}
}
// MARK: - Tense Row
private struct TenseRowView: View {
let tense: TenseInfo
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(tense.english)
.font(.headline)
Text(tense.spanish)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if tense.isCore {
Text("Essential")
.font(.caption2.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.orange.opacity(0.12), in: Capsule())
}
}
}
}
// MARK: - Guide Detail
struct GuideDetailView: View {
let guide: TenseGuide
private var tenseInfo: TenseInfo? {
TenseInfo.find(guide.tenseId)
}
private var endingTable: TenseEndingTable? {
TenseEndingTable.find(guide.tenseId)
}
private var parsedContent: GuideContent {
GuideContent.parse(guide.body)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Header
headerSection
// Conjugation ending table
if let table = endingTable {
conjugationTableSection(table)
}
// Formula
if let table = endingTable {
formulaSection(table)
}
Divider()
// Description
if !parsedContent.description.isEmpty {
descriptionSection
}
// Consolidated usages card
if !parsedContent.usages.isEmpty {
usagesSummarySection
}
// Examples section
if parsedContent.usages.contains(where: { !$0.examples.isEmpty }) {
examplesSection
}
}
.padding()
.adaptiveContainer(maxWidth: 800)
}
.navigationTitle(tenseInfo?.english ?? guide.title)
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Header
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
if let info = tenseInfo {
Text(info.spanish)
.font(.title2.weight(.bold))
Text(info.english)
.font(.title3)
.foregroundStyle(.secondary)
HStack {
Label(info.mood, systemImage: "tag")
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
}
}
// MARK: - Conjugation Table
private func conjugationTableSection(_ table: TenseEndingTable) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Conjugation Endings")
.font(.headline)
if table.isCompound, let aux = table.auxiliaryForms {
// Compound tense: show auxiliary + participle
compoundTenseTable(table, auxiliary: aux)
} else {
// Simple tense: show endings grid
simpleEndingsGrid(table)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
}
private func simpleEndingsGrid(_ table: TenseEndingTable) -> some View {
VStack(spacing: 0) {
// Header row
HStack(spacing: 0) {
Text("")
.frame(width: 80, alignment: .leading)
Text("~ar")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.frame(maxWidth: .infinity)
Text("~er")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.green)
.frame(maxWidth: .infinity)
Text("~ir")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.blue)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 6)
.background(.fill.quaternary)
// Example verb names
HStack(spacing: 0) {
Text("")
.frame(width: 80, alignment: .leading)
Text(table.arExample.verb)
.font(.caption2.italic())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
Text(table.erExample.verb)
.font(.caption2.italic())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
Text(table.irExample.verb)
.font(.caption2.italic())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 4)
Divider()
// Person rows
ForEach(0..<6, id: \.self) { i in
HStack(spacing: 0) {
Text(TenseEndingTable.persons[i])
.font(.subheadline.weight(.medium))
.frame(width: 80, alignment: .leading)
endingCell(table.arExample.stem, ending: table.arEndings[i], color: .orange)
.frame(maxWidth: .infinity)
endingCell(table.erExample.stem, ending: table.erEndings[i], color: .green)
.frame(maxWidth: .infinity)
endingCell(table.irExample.stem, ending: table.irEndings[i], color: .blue)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 5)
if i < 5 {
Divider().opacity(0.5)
}
}
}
}
private func endingCell(_ stem: String, ending: String, color: Color) -> some View {
Group {
if ending == "" {
Text("")
.font(.subheadline)
.foregroundStyle(.tertiary)
} else {
Text(
"\(Text(stem).foregroundStyle(.primary))\(Text(ending).foregroundStyle(color).fontWeight(.semibold))"
)
.font(.subheadline)
}
}
}
private func compoundTenseTable(_ table: TenseEndingTable, auxiliary: [String]) -> some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 0) {
Text("")
.frame(width: 80, alignment: .leading)
Text("Auxiliary")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.purple)
.frame(maxWidth: .infinity)
Text("~ar")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.frame(maxWidth: .infinity)
Text("~er/~ir")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.cyan)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 6)
.background(.fill.quaternary)
Divider()
ForEach(0..<6, id: \.self) { i in
HStack(spacing: 0) {
Text(TenseEndingTable.persons[i])
.font(.subheadline.weight(.medium))
.frame(width: 80, alignment: .leading)
Text(auxiliary[i])
.font(.subheadline)
.foregroundStyle(.purple)
.frame(maxWidth: .infinity)
Text(table.arExample.stem + "ado")
.font(.subheadline)
.foregroundStyle(.orange)
.frame(maxWidth: .infinity)
Text(table.erExample.stem + "ido")
.font(.subheadline)
.foregroundStyle(.cyan)
.frame(maxWidth: .infinity)
}
.padding(.vertical, 5)
if i < 5 {
Divider().opacity(0.5)
}
}
}
}
// MARK: - Formula
private func formulaSection(_ table: TenseEndingTable) -> some View {
VStack(alignment: .leading, spacing: 6) {
Label("How to form", systemImage: "function")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Text(table.formula)
.font(.body.weight(.medium))
Text(table.stemRule)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Description
private var descriptionSection: some View {
Text(parsedContent.description)
.font(.body)
.lineSpacing(4)
}
// MARK: - Usages Summary (consolidated)
private var usagesSummarySection: some View {
VStack(alignment: .leading, spacing: 10) {
Label("When to use", systemImage: "lightbulb")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(parsedContent.usages) { usage in
HStack(alignment: .top, spacing: 10) {
Text("\(usage.number).")
.font(.subheadline.weight(.bold))
.foregroundStyle(.blue)
.frame(width: 20, alignment: .trailing)
Text(usage.title)
.font(.subheadline)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Examples Section
private var examplesSection: some View {
VStack(alignment: .leading, spacing: 16) {
Label("Examples", systemImage: "text.quote")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(parsedContent.usages) { usage in
ForEach(usage.examples) { example in
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish)
.font(.body.weight(.medium))
.italic()
Text(example.english)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
}
}
// MARK: - Content Parsing
struct GuideContent {
let description: String
let usages: [GuideUsage]
static func parse(_ body: String) -> GuideContent {
let lines = body.components(separatedBy: "\n")
var description = ""
var usages: [GuideUsage] = []
var currentUsageTitle = ""
var currentUsageNumber = 0
var currentExamples: [GuideExample] = []
var inUsages = false
var spanishLine: String?
func flushUsage() {
// Only emit a usage if it has at least one example. This suppresses
// the implicit "Usage 1" seeded when we enter an unnumbered
// *Usages* block but the body actually has numbered headers below.
if currentUsageNumber > 0 && !currentExamples.isEmpty {
usages.append(GuideUsage(
number: currentUsageNumber,
title: currentUsageTitle,
examples: currentExamples
))
}
currentExamples = []
}
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip empty lines
if trimmed.isEmpty {
if let sp = spanishLine {
// Spanish line with no English translation
currentExamples.append(GuideExample(spanish: sp, english: ""))
spanishLine = nil
}
continue
}
// Detect usage headers like "*1 Current actions*" or "**1 Current actions**"
let usagePattern = /^\*{1,2}(\d+)\s+(.+?)\*{0,2}$/
if let match = trimmed.firstMatch(of: usagePattern) {
flushUsage()
inUsages = true
currentUsageNumber = Int(match.1) ?? 0
currentUsageTitle = String(match.2)
.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
continue
}
// Detect usage headers without numbers
let usageTitlePattern = /^\*{1,2}(.+?)\*{0,2}$/
if !inUsages, let match = trimmed.firstMatch(of: usageTitlePattern) {
let title = String(match.1).replacingOccurrences(of: "*", with: "")
if title.lowercased().contains("usage") {
inUsages = true
// Seed an implicit Usage 1 so content that follows without a
// numbered "*1 Title*" header still gets captured. Any numbered
// header below will replace this via flushUsage().
currentUsageNumber = 1
currentUsageTitle = "Usage"
continue
}
}
if !inUsages {
// Before usages section, accumulate description
let cleanLine = trimmed
.replacingOccurrences(of: "**", with: "")
.replacingOccurrences(of: "*", with: "")
if !description.isEmpty { description += " " }
description += cleanLine
continue
}
// In usages: detect example pairs (Spanish line followed by English line)
if let sp = spanishLine {
// This line is the English translation of the previous Spanish line
currentExamples.append(GuideExample(spanish: sp, english: trimmed))
spanishLine = nil
} else {
// Check if this looks like a Spanish sentence
let startsWithDash = trimmed.hasPrefix("-") || trimmed.hasPrefix("")
let cleanLine = startsWithDash ? String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) : trimmed
// Heuristic: Spanish sentences contain accented chars or start with ¿/¡
let isSpanish = cleanLine.contains("á") || cleanLine.contains("é") || cleanLine.contains("í") ||
cleanLine.contains("ó") || cleanLine.contains("ú") || cleanLine.contains("ñ") ||
cleanLine.hasPrefix("¿") || cleanLine.hasPrefix("¡") ||
cleanLine.first?.isUppercase == true
if isSpanish && !cleanLine.lowercased().hasPrefix("i ") && !cleanLine.lowercased().hasPrefix("the ") &&
!cleanLine.lowercased().hasPrefix("all ") && !cleanLine.lowercased().hasPrefix("when ") &&
!cleanLine.lowercased().hasPrefix("what ") {
spanishLine = cleanLine
} else {
// English or context line
if currentUsageNumber == 0 && usages.isEmpty {
// Still description
if !description.isEmpty { description += " " }
description += cleanLine
}
}
}
}
flushUsage()
// If no usages were found, create one from the whole body
if usages.isEmpty && !body.isEmpty {
usages.append(GuideUsage(number: 1, title: "Usage", examples: []))
}
return GuideContent(description: description, usages: usages)
}
}
struct GuideUsage: Identifiable {
let number: Int
let title: String
let examples: [GuideExample]
var id: Int { number }
}
struct GuideExample: Identifiable {
let spanish: String
let english: String
var id: String { spanish }
}
#Preview {
GuideView()
.modelContainer(for: TenseGuide.self, inMemory: true)
}