development
swift 335 lines 12.3 KB
Raw
1 import SwiftUI
2
3 struct DiscoverView: View {
4 @EnvironmentObject private var appState: AppState
5 @EnvironmentObject private var guideEngine: OndeGuideEngine
6 @EnvironmentObject private var recentlyViewedStore: RecentlyViewedStore
7
8 @Binding var showingSettings: Bool
9
10 #if os(iOS)
11 @Environment(\.horizontalSizeClass) private var horizontalSizeClass
12 #endif
13
14 private var featuredEntry: GuideEntry? {
15 let featuredEntries = appState.contentRepository.featuredEntries
16 guard !featuredEntries.isEmpty else { return nil }
17 let dayIndex = Calendar.current.ordinality(of: .day, in: .year, for: Date()) ?? 0
18 return featuredEntries[dayIndex % featuredEntries.count]
19 }
20
21 private var starterEntries: [GuideEntry] {
22 Array(appState.contentRepository.featuredEntries.prefix(4))
23 }
24
25 private var featuredTasteNotes: [String] {
26 ["Private on your device", "Curated first", "Warm follow-up answers"]
27 }
28
29 private var recentlyViewedEntries: [GuideEntry] {
30 recentlyViewedStore.entryIDs.compactMap { appState.contentRepository.entry(id: $0) }
31 }
32
33 private var showsSettingsButton: Bool {
34 #if os(iOS)
35 return horizontalSizeClass != .regular
36 #else
37 return true
38 #endif
39 }
40
41 var body: some View {
42 ScrollView {
43 VStack(alignment: .leading, spacing: 24) {
44 #if os(tvOS)
45 TVPageHeader("Klepon")
46 #endif
47
48 introSection
49
50 if let featuredEntry {
51 SectionHeader("Featured today", subtitle: "A thoughtful place to start")
52
53 NavigationLink {
54 GuideDetailView(entry: featuredEntry)
55 } label: {
56 FeaturedEntryCard(entry: featuredEntry)
57 }
58 .kleponInteractiveButtonStyle()
59 } else {
60 EmptyStateView(
61 title: "Curated dishes are on the way",
62 message:
63 "Add a few starter entries to your bundled content and Discover will come to life.",
64 systemImage: "fork.knife.circle"
65 )
66 }
67
68 if !appState.contentRepository.collections.isEmpty {
69 VStack(alignment: .leading, spacing: 18) {
70 SectionHeader(
71 "Browse by collection",
72 subtitle: "Curated paths through taste, place, and tradition")
73
74 ForEach(appState.contentRepository.collections) { collection in
75 CollectionRail(
76 collection: collection,
77 entries: appState.contentRepository.entries(in: collection))
78 }
79 }
80 }
81
82 if !starterEntries.isEmpty {
83 VStack(alignment: .leading, spacing: 14) {
84 SectionHeader(
85 "Good first dishes",
86 subtitle: "Iconic flavors to open the app with confidence")
87
88 ForEach(starterEntries) { entry in
89 NavigationLink {
90 GuideDetailView(entry: entry)
91 } label: {
92 EntryListCard(entry: entry)
93 }
94 .kleponInteractiveButtonStyle()
95 }
96 }
97 }
98
99 if !recentlyViewedEntries.isEmpty {
100 VStack(alignment: .leading, spacing: 14) {
101 SectionHeader(
102 "Pick up where you left off",
103 subtitle: "Recently opened dishes, ingredients, and food notes")
104
105 ForEach(Array(recentlyViewedEntries.prefix(3))) { entry in
106 NavigationLink {
107 GuideDetailView(entry: entry)
108 } label: {
109 EntryListCard(entry: entry)
110 }
111 .kleponInteractiveButtonStyle()
112 }
113 }
114 }
115
116 privateGuideCard
117 }
118 .padding(.horizontal, 20)
119 .padding(.vertical, 16)
120 }
121 .background(KleponColor.background.ignoresSafeArea())
122 .navigationTitle("Klepon")
123 #if os(tvOS)
124 .toolbar(.hidden, for: .navigationBar)
125 #else
126 .kleponLargeNavigationTitle()
127 #endif
128 #if !os(tvOS)
129 .toolbar {
130 if showsSettingsButton {
131 ToolbarItem(placement: .kleponPrimaryAction) {
132 Button {
133 showingSettings = true
134 } label: {
135 Image(systemName: "gearshape.2")
136 }
137 .tint(KleponColor.accent)
138 }
139 }
140 }
141 #endif
142 }
143
144 private var introSection: some View {
145 KleponCard(padding: 22) {
146 VStack(alignment: .leading, spacing: 14) {
147 Text("Taste of Indonesia")
148 .font(KleponTypography.heroTitle)
149 .foregroundStyle(KleponColor.textPrimary)
150
151 Text(
152 "Explore dishes, ingredients, and food traditions with a guide that feels calm, personal, and private on your device."
153 )
154 .font(KleponTypography.body)
155 .foregroundStyle(KleponColor.textSecondary)
156
157 FlexibleDiscoverTagLayout(items: featuredTasteNotes)
158 }
159 }
160 }
161
162 private var privateGuideCard: some View {
163 KleponCard {
164 VStack(alignment: .leading, spacing: 14) {
165 HStack(alignment: .top, spacing: 12) {
166 Image(systemName: guideEngine.availability == .ready ? "lock.fill" : "sparkles")
167 .font(.title3)
168 .foregroundStyle(KleponColor.accent)
169 .padding(10)
170 .background(Circle().fill(KleponColor.surfaceSecondary))
171
172 VStack(alignment: .leading, spacing: 6) {
173 Text("Ask privately")
174 .font(KleponTypography.cardTitle)
175 .foregroundStyle(KleponColor.textPrimary)
176
177 Text(guideEngine.availability.title)
178 .font(KleponTypography.caption)
179 .foregroundStyle(
180 guideEngine.availability.isFailure
181 ? KleponColor.highlight : KleponColor.accentWarm)
182 }
183 }
184
185 Text(guideEngine.availability.detail)
186 .font(KleponTypography.bodySecondary)
187 .foregroundStyle(KleponColor.textSecondary)
188
189 HStack(spacing: 10) {
190 KleponChip(
191 title: guideEngine.estimatedDownloadDescription, icon: "arrow.down.circle")
192 KleponChip(title: guideEngine.storageUsedDescription, icon: "internaldrive")
193 }
194
195 if guideEngine.availability == .ready {
196 Text(
197 "Open any dish to ask taste, ingredient, or comparison questions without leaving the guide."
198 )
199 .font(KleponTypography.caption)
200 .foregroundStyle(KleponColor.accentWarm)
201 }
202
203 KleponActionButton(
204 title: guideEngine.availability.actionTitle,
205 systemImage: guideEngine.availability == .ready
206 ? "checkmark.circle.fill" : "arrow.down.circle",
207 isLoading: guideEngine.availability.isBusy,
208 isDisabled: guideEngine.availability == .ready
209 ) {
210 Task {
211 await guideEngine.prepareIfNeeded(
212 forceReload: guideEngine.availability.isFailure)
213 }
214 }
215 }
216 }
217 }
218 }
219
220 private struct FeaturedEntryCard: View {
221 let entry: GuideEntry
222
223 var body: some View {
224 KleponCard(padding: 20) {
225 VStack(alignment: .leading, spacing: 14) {
226 GuideArtworkView(entry: entry, height: 178)
227
228 KleponChip(title: entry.kind.displayTitle, icon: entry.kind.symbolName)
229
230 Text(entry.summary)
231 .font(KleponTypography.body)
232 .foregroundStyle(KleponColor.textSecondary)
233
234 FlexibleDiscoverTagLayout(items: Array(entry.tasteNotes.prefix(3)))
235 }
236 }
237 }
238 }
239
240 private struct EntryListCard: View {
241 let entry: GuideEntry
242
243 var body: some View {
244 KleponCard {
245 VStack(alignment: .leading, spacing: 10) {
246 HStack {
247 Text(entry.title)
248 .font(KleponTypography.cardTitle)
249 .foregroundStyle(KleponColor.textPrimary)
250
251 Spacer(minLength: 12)
252
253 Image(systemName: entry.kind.symbolName)
254 .foregroundStyle(KleponColor.accentWarm)
255 }
256
257 Text(entry.summary)
258 .font(KleponTypography.bodySecondary)
259 .foregroundStyle(KleponColor.textSecondary)
260 .lineLimit(2)
261 }
262 }
263 }
264 }
265
266 private struct FlexibleDiscoverTagLayout: View {
267 let items: [String]
268
269 var body: some View {
270 LazyVGrid(
271 columns: [GridItem(.adaptive(minimum: 110), spacing: 10)], alignment: .leading,
272 spacing: 10
273 ) {
274 ForEach(items, id: \.self) { item in
275 KleponChip(title: item)
276 }
277 }
278 }
279 }
280
281 private struct CollectionRail: View {
282 let collection: GuideCollection
283 let entries: [GuideEntry]
284
285 var body: some View {
286 VStack(alignment: .leading, spacing: 12) {
287 VStack(alignment: .leading, spacing: 4) {
288 Text(collection.title)
289 .font(KleponTypography.cardTitle)
290 .foregroundStyle(KleponColor.textPrimary)
291
292 Text(collection.subtitle)
293 .font(KleponTypography.bodySecondary)
294 .foregroundStyle(KleponColor.textSecondary)
295 }
296
297 ScrollView(.horizontal, showsIndicators: false) {
298 HStack(spacing: 14) {
299 ForEach(entries) { entry in
300 NavigationLink {
301 GuideDetailView(entry: entry)
302 } label: {
303 VStack(alignment: .leading, spacing: 10) {
304 Text(entry.title)
305 .font(KleponTypography.cardTitle)
306 .foregroundStyle(KleponColor.textPrimary)
307 .lineLimit(2)
308
309 Text(entry.subtitle)
310 .font(KleponTypography.caption)
311 .foregroundStyle(KleponColor.accentWarm)
312 .lineLimit(2)
313
314 Spacer(minLength: 0)
315
316 Text(entry.summary)
317 .font(KleponTypography.bodySecondary)
318 .foregroundStyle(KleponColor.textSecondary)
319 .lineLimit(3)
320 }
321 .padding(16)
322 .frame(width: 220, alignment: .leading)
323 .frame(minHeight: 160, alignment: .topLeading)
324 .background(
325 RoundedRectangle(cornerRadius: 22, style: .continuous)
326 .fill(KleponColor.surfaceSecondary)
327 )
328 }
329 .kleponInteractiveButtonStyle()
330 }
331 }
332 }
333 }
334 }
335 }