main
swift 252 lines 9.4 KB
Raw
1 import SwiftUI
2
3 struct GuideDetailView: View {
4 let entry: GuideEntry
5
6 @EnvironmentObject private var appState: AppState
7 @EnvironmentObject private var favoritesStore: FavoritesStore
8 @EnvironmentObject private var recentlyViewedStore: RecentlyViewedStore
9
10 @State private var showingAskSheet = false
11 @State private var showingUnsupportedAlert = false
12 @State private var initialQuestion: String?
13
14 private var relatedEntries: [GuideEntry] {
15 appState.contentRepository.relatedEntries(for: entry)
16 }
17
18 var body: some View {
19 ScrollView {
20 VStack(alignment: .leading, spacing: 20) {
21 headerCard
22 .tvOSFocusable()
23 richTextSection(title: "What it is", text: entry.story)
24 .tvOSFocusable()
25
26 if !entry.tasteNotes.isEmpty {
27 VStack(alignment: .leading, spacing: 12) {
28 SectionHeader("What it tastes like")
29
30 FlexibleTagLayout(items: entry.tasteNotes)
31 }
32 .tvOSFocusable()
33 }
34
35 if !entry.highlights.isEmpty {
36 VStack(alignment: .leading, spacing: 12) {
37 SectionHeader("Good to know")
38
39 KleponCard {
40 VStack(alignment: .leading, spacing: 12) {
41 ForEach(entry.highlights, id: \.self) { item in
42 Label(item, systemImage: "circle.fill")
43 .font(KleponTypography.bodySecondary)
44 .foregroundStyle(KleponColor.textSecondary)
45 .labelStyle(DetailBulletLabelStyle())
46 }
47 }
48 }
49 }
50 .tvOSFocusable()
51 }
52
53 if let region = entry.region {
54 richTextSection(title: "Region or context", text: region)
55 .tvOSFocusable()
56 }
57
58 askSection
59
60 if !relatedEntries.isEmpty {
61 VStack(alignment: .leading, spacing: 12) {
62 SectionHeader("Similar dishes and related notes")
63
64 ForEach(relatedEntries) { relatedEntry in
65 NavigationLink {
66 GuideDetailView(entry: relatedEntry)
67 } label: {
68 KleponCard {
69 VStack(alignment: .leading, spacing: 10) {
70 HStack {
71 Text(relatedEntry.title)
72 .font(KleponTypography.cardTitle)
73 .foregroundStyle(KleponColor.textPrimary)
74
75 Spacer(minLength: 12)
76
77 Text(relatedEntry.kind.displayTitle)
78 .font(KleponTypography.caption)
79 .foregroundStyle(KleponColor.accentWarm)
80 }
81
82 Text(relatedEntry.summary)
83 .font(KleponTypography.bodySecondary)
84 .foregroundStyle(KleponColor.textSecondary)
85 .lineLimit(2)
86 }
87 }
88 }
89 .kleponInteractiveButtonStyle()
90 }
91 }
92 }
93 }
94 .padding(.horizontal, 20)
95 .padding(.vertical, 16)
96 }
97 .background(KleponColor.background.ignoresSafeArea())
98 .navigationTitle(entry.title)
99 .kleponInlineNavigationTitle()
100 .toolbar {
101 ToolbarItem(placement: .kleponPrimaryAction) {
102 Button {
103 favoritesStore.toggle(entry.id)
104 } label: {
105 Image(systemName: favoritesStore.isFavorite(entry.id) ? "heart.fill" : "heart")
106 }
107 .tint(KleponColor.highlight)
108 }
109 }
110 .sheet(isPresented: $showingAskSheet) {
111 NavigationStack {
112 AskSheetView(entry: entry, initialQuestion: initialQuestion)
113 }
114 }
115 .alert(
116 "Not available on this device",
117 isPresented: $showingUnsupportedAlert
118 ) {
119 Button("OK", role: .cancel) {}
120 } message: {
121 Text(
122 "The private guide requires Apple TV 4K 3rd generation (2022) or newer. "
123 + "Older models don't have the hardware needed to run on-device inference."
124 )
125 }
126 .onAppear {
127 recentlyViewedStore.record(entry.id)
128 }
129 }
130
131 private func openAskSheet(question: String? = nil) {
132 #if os(tvOS)
133 if !kleponDeviceSupportsInference() {
134 showingUnsupportedAlert = true
135 return
136 }
137 #endif
138 initialQuestion = question
139 showingAskSheet = true
140 }
141
142 private var headerCard: some View {
143 KleponCard(padding: 20) {
144 VStack(alignment: .leading, spacing: 16) {
145 GuideArtworkView(entry: entry, height: 184)
146
147 HStack(spacing: 10) {
148 KleponChip(title: entry.kind.displayTitle, icon: entry.kind.symbolName)
149
150 if let region = entry.region {
151 KleponChip(title: region)
152 }
153 }
154
155 Text(entry.summary)
156 .font(KleponTypography.body)
157 .foregroundStyle(KleponColor.textSecondary)
158 }
159 }
160 }
161
162 private func richTextSection(title: String, text: String) -> some View {
163 VStack(alignment: .leading, spacing: 12) {
164 SectionHeader(title)
165
166 KleponCard {
167 Text(text)
168 .font(KleponTypography.body)
169 .foregroundStyle(KleponColor.textSecondary)
170 }
171 }
172 }
173
174 private var askSection: some View {
175 KleponCard {
176 VStack(alignment: .leading, spacing: 14) {
177 Text("Ask privately about \(entry.title)")
178 .font(KleponTypography.cardTitle)
179 .foregroundStyle(KleponColor.textPrimary)
180
181 Text(
182 "Get a warmer explanation, compare dishes, or ask what to try next without turning this into a generic chat screen."
183 )
184 .font(KleponTypography.bodySecondary)
185 .foregroundStyle(KleponColor.textSecondary)
186
187 if !entry.suggestedQuestions.isEmpty {
188 VStack(alignment: .leading, spacing: 10) {
189 Text("Try a quick question")
190 .font(KleponTypography.caption)
191 .foregroundStyle(KleponColor.accentWarm)
192
193 LazyVGrid(
194 columns: [GridItem(.adaptive(minimum: 150), spacing: 10)],
195 alignment: .leading,
196 spacing: 10
197 ) {
198 ForEach(Array(entry.suggestedQuestions.prefix(3)), id: \.self) {
199 question in
200 Button {
201 openAskSheet(question: question)
202 } label: {
203 Text(question)
204 .font(KleponTypography.bodySecondary)
205 .multilineTextAlignment(.leading)
206 .frame(maxWidth: .infinity, alignment: .leading)
207 .padding(12)
208 .background(
209 RoundedRectangle(cornerRadius: 16, style: .continuous)
210 .fill(KleponColor.surfaceSecondary)
211 )
212 .foregroundStyle(KleponColor.textPrimary)
213 }
214 .kleponInteractiveButtonStyle()
215 }
216 }
217 }
218 }
219
220 KleponActionButton(title: "Ask privately", systemImage: "lock") {
221 openAskSheet()
222 }
223 }
224 }
225 }
226 }
227
228 private struct DetailBulletLabelStyle: LabelStyle {
229 func makeBody(configuration: Configuration) -> some View {
230 HStack(alignment: .top, spacing: 10) {
231 configuration.icon
232 .font(.system(size: 6))
233 .padding(.top, 8)
234 configuration.title
235 }
236 }
237 }
238
239 private struct FlexibleTagLayout: View {
240 let items: [String]
241
242 var body: some View {
243 LazyVGrid(
244 columns: [GridItem(.adaptive(minimum: 96), spacing: 10)], alignment: .leading,
245 spacing: 10
246 ) {
247 ForEach(items, id: \.self) { item in
248 KleponChip(title: item)
249 }
250 }
251 }
252 }