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) }