Files
Spanish/Conjuga/Conjuga/Views/Guide/GuideView.swift
T
Trey T 9aa4d0836d Guide — bidirectional cross-link chips between tense guides & grammar notes
After the full enrichment pass, both the Tenses and Grammar surfaces of
the Guide tab cover overlapping material — WEIRDO appears in both
"Subjuntivo Presente" and "Subjunctive Triggers", preterite↔imperfect
contrast in three places, etc. Instead of trimming either body and
losing content, add a small chip row at the top of each detail view
linking directly across.

GuideCrossLinks.swift (new) — curated tense→[noteId] map covering 18 of
the 20 tenses. The two without aligned notes (ind_pluscuamperfecto,
ind_preterito_anterior) don't show chips. The reverse map (noteId→
[tenseId]) is derived once at static init and sorted by canonical tense
order so chips appear in conjugation-table order.

GuideDetailView — "Related grammar" indigo chip row directly under the
header. Tap a chip → switch to the Grammar segment with that note
selected.

GrammarNoteDetailView — "Used in tenses" orange chip row directly under
the title. Tap a chip → switch to the Tenses segment with that tense
selected.

The GuideView segment-change handler now only clears the *other* tab's
selection so programmatic jumps keep their destination intact; manual
segment swipes still feel "fresh" like before.

No content is removed. Users get a deeper-dive path one tap away in
either direction, and the redundancy becomes a feature instead of a
maintenance hazard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:59:46 -05:00

656 lines
23 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) { _, newTab in
// Only clear the *other* tab's selection so programmatic
// cross-link jumps (chip taps in the detail pane) can keep
// their newly-set selection on the destination tab.
switch newTab {
case .tenses: selectedNote = nil
case .grammar: selectedGuide = nil
}
}
} detail: {
if let guide = selectedGuide {
GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
} else if let note = selectedNote {
GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
} 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 jumpToNote(_ note: GrammarNote) {
selectedTab = .grammar
selectedNote = note
}
private func jumpToTense(_ guide: TenseGuide) {
selectedTab = .tenses
selectedGuide = guide
}
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
var onJumpToNote: ((GrammarNote) -> Void)? = nil
@Environment(YouTubeVideoStore.self) private var videoStore
private var relatedNotes: [GrammarNote] {
GuideCrossLinks.noteIds(forTense: guide.tenseId).compactMap { id in
GrammarNote.allNotesIncludingGenerated.first { $0.id == id }
}
}
private var tenseInfo: TenseInfo? {
TenseInfo.find(guide.tenseId)
}
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
videoStore.video(forTenseId: 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
// Related grammar notes cross-links into the Grammar tab
if !relatedNotes.isEmpty {
relatedNotesSection
}
// Video section (Issue #21)
videoSection
// 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: - Related grammar notes
private var relatedNotesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Related grammar", systemImage: "book")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(relatedNotes, id: \.id) { note in
Button {
onJumpToNote?(note)
} label: {
Text(note.title)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(.indigo.opacity(0.12), in: Capsule())
.foregroundStyle(.indigo)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Video (Issue #21)
@ViewBuilder
private var videoSection: some View {
if let video = curatedVideo {
VideoActionsButtonRow(video: video)
} else {
Label("No video yet", systemImage: "play.slash")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(.fill.quinary, in: Capsule())
}
}
// 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)
.environment(YouTubeVideoStore())
}