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 */; };
|
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
|
||||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
|
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
|
||||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.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 */; };
|
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
|
||||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
|
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
|
||||||
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
A661ADF1141176EE96774138 /* BookSpeechController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
|
||||||
@@ -382,6 +384,7 @@
|
|||||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||||
|
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -774,6 +777,7 @@
|
|||||||
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */,
|
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */,
|
||||||
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
|
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
|
||||||
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
|
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
|
||||||
|
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
|
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
|
||||||
struct GrammarNotesView: View {
|
struct GrammarNotesView: View {
|
||||||
@@ -67,12 +69,25 @@ private struct GrammarNoteRow: View {
|
|||||||
|
|
||||||
struct GrammarNoteDetailView: View {
|
struct GrammarNoteDetailView: View {
|
||||||
let note: GrammarNote
|
let note: GrammarNote
|
||||||
|
var onJumpToTense: ((TenseGuide) -> Void)? = nil
|
||||||
@Environment(YouTubeVideoStore.self) private var videoStore
|
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||||
|
@State private var relatedTenses: [TenseGuide] = []
|
||||||
|
|
||||||
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||||
videoStore.video(forGrammarNoteId: note.id)
|
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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
@@ -88,6 +103,10 @@ struct GrammarNoteDetailView: View {
|
|||||||
.background(.fill.tertiary, in: Capsule())
|
.background(.fill.tertiary, in: Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !relatedTenses.isEmpty {
|
||||||
|
relatedTensesSection
|
||||||
|
}
|
||||||
|
|
||||||
videoSection
|
videoSection
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
@@ -114,6 +133,35 @@ struct GrammarNoteDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(note.title)
|
.navigationTitle(note.title)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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
|
@ViewBuilder
|
||||||
|
|||||||
@@ -41,15 +41,20 @@ struct GuideView: View {
|
|||||||
.navigationTitle("Guide")
|
.navigationTitle("Guide")
|
||||||
.task { loadGuides() }
|
.task { loadGuides() }
|
||||||
.onAppear(perform: loadGuides)
|
.onAppear(perform: loadGuides)
|
||||||
.onChange(of: selectedTab) { _, _ in
|
.onChange(of: selectedTab) { _, newTab in
|
||||||
selectedGuide = nil
|
// Only clear the *other* tab's selection so programmatic
|
||||||
selectedNote = nil
|
// 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: {
|
} detail: {
|
||||||
if let guide = selectedGuide {
|
if let guide = selectedGuide {
|
||||||
GuideDetailView(guide: guide)
|
GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
|
||||||
} else if let note = selectedNote {
|
} else if let note = selectedNote {
|
||||||
GrammarNoteDetailView(note: note)
|
GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
|
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)
|
GrammarNotesListView(selectedNote: $selectedNote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func jumpToNote(_ note: GrammarNote) {
|
||||||
|
selectedTab = .grammar
|
||||||
|
selectedNote = note
|
||||||
|
}
|
||||||
|
|
||||||
|
private func jumpToTense(_ guide: TenseGuide) {
|
||||||
|
selectedTab = .tenses
|
||||||
|
selectedGuide = guide
|
||||||
|
}
|
||||||
|
|
||||||
private func loadGuides() {
|
private func loadGuides() {
|
||||||
// Hit the shared local container directly, bypassing @Environment.
|
// Hit the shared local container directly, bypassing @Environment.
|
||||||
guard let container = SharedStore.localContainer else {
|
guard let container = SharedStore.localContainer else {
|
||||||
@@ -127,8 +142,15 @@ private struct TenseRowView: View {
|
|||||||
|
|
||||||
struct GuideDetailView: View {
|
struct GuideDetailView: View {
|
||||||
let guide: TenseGuide
|
let guide: TenseGuide
|
||||||
|
var onJumpToNote: ((GrammarNote) -> Void)? = nil
|
||||||
@Environment(YouTubeVideoStore.self) private var videoStore
|
@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? {
|
private var tenseInfo: TenseInfo? {
|
||||||
TenseInfo.find(guide.tenseId)
|
TenseInfo.find(guide.tenseId)
|
||||||
}
|
}
|
||||||
@@ -151,6 +173,11 @@ struct GuideDetailView: View {
|
|||||||
// Header
|
// Header
|
||||||
headerSection
|
headerSection
|
||||||
|
|
||||||
|
// Related grammar notes — cross-links into the Grammar tab
|
||||||
|
if !relatedNotes.isEmpty {
|
||||||
|
relatedNotesSection
|
||||||
|
}
|
||||||
|
|
||||||
// Video section (Issue #21)
|
// Video section (Issue #21)
|
||||||
videoSection
|
videoSection
|
||||||
|
|
||||||
@@ -188,6 +215,35 @@ struct GuideDetailView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.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)
|
// MARK: - Video (Issue #21)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
Reference in New Issue
Block a user