| 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 | } |