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>
This commit is contained in:
Trey T
2026-05-12 10:59:46 -05:00
parent 5db4b014a9
commit 9aa4d0836d
4 changed files with 221 additions and 5 deletions
@@ -96,6 +96,7 @@
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; };
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */; };
@@ -230,6 +231,7 @@
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
A661ADF1141176EE96774138 /* BookSpeechController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
@@ -382,6 +384,7 @@
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */,
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -774,6 +777,7 @@
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */,
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -0,0 +1,108 @@
import Foundation
/// Maps tense guides grammar notes for the Guide tab's cross-link chips.
/// The forward map is the curated source; the reverse map is derived once.
///
/// A tense ID appears here only if at least one grammar note in
/// `GrammarNote.allNotesIncludingGenerated` covers a concept directly tied
/// to that tense (forms, contrast, triggers, choice). Two tenses currently
/// have no aligned notes and don't appear: `ind_pluscuamperfecto` and
/// `ind_preterito_anterior`.
enum GuideCrossLinks {
/// Tense ID ordered grammar note IDs that go deeper on this tense.
/// Order matters the first chip is the most-relevant note for the
/// tense's primary teaching point.
static let relatedNotes: [String: [String]] = [
"ind_presente": [
"present-indicative-conjugation",
"irregular-yo-verbs",
"stem-changing-verbs",
"estar-gerund-progressive",
],
"ind_preterito": [
"preterite-vs-imperfect",
"stem-changing-verbs",
],
"ind_imperfecto": [
"preterite-vs-imperfect",
],
"ind_futuro": [
"future-vs-ir-a",
],
"ind_perfecto": [
"present-perfect-tense",
],
"ind_futuro_perfecto": [
"future-perfect-tense",
],
"cond_presente": [
"conditional-if-clauses",
],
"cond_perfecto": [
"conditional-if-clauses",
],
"subj_presente": [
"subjunctive-triggers",
"irregular-yo-verbs",
"stem-changing-verbs",
],
"subj_imperfecto_1": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_imperfecto_2": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_perfecto": [
"subjunctive-triggers",
],
"subj_pluscuamperfecto_1": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_pluscuamperfecto_2": [
"subjunctive-triggers",
"conditional-if-clauses",
],
"subj_futuro": [
"subjunctive-triggers",
],
"subj_futuro_perfecto": [
"subjunctive-triggers",
],
"imp_afirmativo": [
"commands-imperative",
],
"imp_negativo": [
"commands-imperative",
"subjunctive-triggers",
],
]
/// Grammar note ID tense IDs that point at this note, ordered by the
/// shared `TenseInfo.order` so chips appear in canonical conjugation
/// order.
static let relatedTenses: [String: [String]] = {
var inverse: [String: [String]] = [:]
for (tenseId, noteIds) in relatedNotes {
for noteId in noteIds {
inverse[noteId, default: []].append(tenseId)
}
}
for key in inverse.keys {
inverse[key]?.sort { lhs, rhs in
(TenseInfo.find(lhs)?.order ?? 999) < (TenseInfo.find(rhs)?.order ?? 999)
}
}
return inverse
}()
static func noteIds(forTense tenseId: String) -> [String] {
relatedNotes[tenseId] ?? []
}
static func tenseIds(forNote noteId: String) -> [String] {
relatedTenses[noteId] ?? []
}
}
@@ -1,4 +1,6 @@
import SwiftUI
import SwiftData
import SharedModels
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
struct GrammarNotesView: View {
@@ -67,12 +69,25 @@ private struct GrammarNoteRow: View {
struct GrammarNoteDetailView: View {
let note: GrammarNote
var onJumpToTense: ((TenseGuide) -> Void)? = nil
@Environment(YouTubeVideoStore.self) private var videoStore
@State private var relatedTenses: [TenseGuide] = []
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
videoStore.video(forGrammarNoteId: note.id)
}
private func loadRelatedTenses() {
guard let container = SharedStore.localContainer else {
relatedTenses = []
return
}
let context = ModelContext(container)
let guides = ReferenceStore(context: context).fetchGuides()
let byId = Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
relatedTenses = GuideCrossLinks.tenseIds(forNote: note.id).compactMap { byId[$0] }
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
@@ -88,6 +103,10 @@ struct GrammarNoteDetailView: View {
.background(.fill.tertiary, in: Capsule())
}
if !relatedTenses.isEmpty {
relatedTensesSection
}
videoSection
Divider()
@@ -114,6 +133,35 @@ struct GrammarNoteDetailView: View {
}
.navigationTitle(note.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadRelatedTenses)
.onChange(of: note.id) { _, _ in loadRelatedTenses() }
}
private var relatedTensesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Used in tenses", systemImage: "clock.arrow.circlepath")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(relatedTenses, id: \.tenseId) { guide in
Button {
onJumpToTense?(guide)
} label: {
Text(TenseInfo.find(guide.tenseId)?.english ?? guide.title)
.font(.caption.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(.orange.opacity(0.12), in: Capsule())
.foregroundStyle(.orange)
}
.buttonStyle(.plain)
}
}
}
}
}
@ViewBuilder
+61 -5
View File
@@ -41,15 +41,20 @@ struct GuideView: View {
.navigationTitle("Guide")
.task { loadGuides() }
.onAppear(perform: loadGuides)
.onChange(of: selectedTab) { _, _ in
selectedGuide = nil
selectedNote = nil
.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)
GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
} else if let note = selectedNote {
GrammarNoteDetailView(note: note)
GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
} else {
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
}
@@ -79,6 +84,16 @@ struct GuideView: 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 {
@@ -127,8 +142,15 @@ private struct TenseRowView: View {
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)
}
@@ -151,6 +173,11 @@ struct GuideDetailView: View {
// Header
headerSection
// Related grammar notes cross-links into the Grammar tab
if !relatedNotes.isEmpty {
relatedNotesSection
}
// Video section (Issue #21)
videoSection
@@ -188,6 +215,35 @@ struct GuideDetailView: View {
.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