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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user