diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 8ebdf7b..223a9ca 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = ""; }; A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = ""; }; A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = ""; }; A661ADF1141176EE96774138 /* BookSpeechController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = ""; }; @@ -382,6 +384,7 @@ 3BC3247457109FC6BF00D85B /* TenseInfo.swift */, DAFE27F29412021AEC57E728 /* TestResult.swift */, E536AD1180FE10576EAC884A /* UserProgress.swift */, + A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */, ); path = Models; sourceTree = ""; @@ -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; }; diff --git a/Conjuga/Conjuga/Models/GuideCrossLinks.swift b/Conjuga/Conjuga/Models/GuideCrossLinks.swift new file mode 100644 index 0000000..716c375 --- /dev/null +++ b/Conjuga/Conjuga/Models/GuideCrossLinks.swift @@ -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] ?? [] + } +} diff --git a/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift b/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift index b735955..b3ae73a 100644 --- a/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift +++ b/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift @@ -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 diff --git a/Conjuga/Conjuga/Views/Guide/GuideView.swift b/Conjuga/Conjuga/Views/Guide/GuideView.swift index 2b44032..f2bb52c 100644 --- a/Conjuga/Conjuga/Views/Guide/GuideView.swift +++ b/Conjuga/Conjuga/Views/Guide/GuideView.swift @@ -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