main
swift 198 lines 7.53 KB
Raw
1 import SwiftUI
2
3 struct SearchView: View {
4 @EnvironmentObject private var appState: AppState
5 @EnvironmentObject private var recentSearchStore: RecentSearchStore
6
7 @State private var query = ""
8
9 private var results: [GuideEntry] {
10 appState.searchService.results(for: query)
11 }
12
13 private var recentEntries: [GuideEntry] {
14 appState.recentlyViewedStore.entryIDs.compactMap {
15 appState.contentRepository.entry(id: $0)
16 }
17 }
18
19 var body: some View {
20 ScrollView {
21 VStack(alignment: .leading, spacing: 20) {
22 #if os(tvOS)
23 TVPageHeader("Search")
24 tvSearchField
25 #endif
26
27 if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
28 if !recentSearchStore.queries.isEmpty {
29 VStack(alignment: .leading, spacing: 12) {
30 SectionHeader("Recent searches")
31
32 LazyVGrid(
33 columns: [GridItem(.adaptive(minimum: 120), spacing: 10)],
34 alignment: .leading,
35 spacing: 10
36 ) {
37 ForEach(recentSearchStore.queries, id: \.self) { recentQuery in
38 Button {
39 query = recentQuery
40 } label: {
41 KleponChip(title: recentQuery, icon: "clock")
42 }
43 .kleponInteractiveButtonStyle()
44 }
45 }
46 }
47 }
48
49 SectionHeader(
50 "Start with something familiar",
51 subtitle: "Try a dish, ingredient, or food tradition")
52
53 ForEach(appState.searchService.suggestions()) { entry in
54 NavigationLink {
55 GuideDetailView(entry: entry)
56 } label: {
57 SearchResultCard(entry: entry)
58 }
59 .simultaneousGesture(
60 TapGesture().onEnded {
61 appState.recentSearchStore.record(entry.title)
62 }
63 )
64 .kleponInteractiveButtonStyle()
65 }
66
67 if !recentEntries.isEmpty {
68 VStack(alignment: .leading, spacing: 12) {
69 SectionHeader("Recently viewed", subtitle: "Pick up where you left off")
70
71 ForEach(Array(recentEntries.prefix(3))) { entry in
72 NavigationLink {
73 GuideDetailView(entry: entry)
74 } label: {
75 SearchResultCard(entry: entry)
76 }
77 .kleponInteractiveButtonStyle()
78 }
79 }
80 }
81 } else if results.isEmpty {
82 EmptyStateView(
83 title: "No exact match yet",
84 message:
85 "Try another spelling or open a dish and ask a private follow-up question from there.",
86 systemImage: "magnifyingglass"
87 )
88 } else {
89 SectionHeader("Results", subtitle: "Curated matches from your local guide")
90
91 ForEach(results) { entry in
92 NavigationLink {
93 GuideDetailView(entry: entry)
94 } label: {
95 SearchResultCard(entry: entry)
96 }
97 .simultaneousGesture(
98 TapGesture().onEnded {
99 appState.recentSearchStore.record(query)
100 }
101 )
102 .kleponInteractiveButtonStyle()
103 }
104 }
105 }
106 .padding(.horizontal, 20)
107 .padding(.vertical, 16)
108 }
109 .background(KleponColor.background.ignoresSafeArea())
110 .navigationTitle("Search")
111 #if os(tvOS)
112 .toolbar(.hidden, for: .navigationBar)
113 #else
114 .searchable(text: $query, prompt: "Try rendang, sambal, or klepon")
115 .onSubmit(of: .search) {
116 appState.recentSearchStore.record(query)
117 }
118 #endif
119 }
120
121 #if os(tvOS)
122 private var tvSearchField: some View {
123 KleponCard(padding: 18) {
124 HStack(spacing: 12) {
125 Image(systemName: "magnifyingglass")
126 .font(.title3)
127 .foregroundStyle(KleponColor.accentWarm)
128
129 TextField("Try rendang, sambal, or klepon", text: $query)
130 .font(KleponTypography.body)
131 .foregroundColor(KleponColor.textPrimary)
132 .textFieldStyle(.plain)
133 .submitLabel(.search)
134 .onSubmit {
135 appState.recentSearchStore.record(query)
136 }
137
138 if !query.isEmpty {
139 Button {
140 query = ""
141 } label: {
142 Image(systemName: "xmark.circle.fill")
143 .font(.title3)
144 .foregroundStyle(KleponColor.textSecondary)
145 }
146 .buttonStyle(.plain)
147 }
148 }
149 .padding(.horizontal, 16)
150 .padding(.vertical, 14)
151 .background(
152 RoundedRectangle(cornerRadius: 18, style: .continuous)
153 .fill(KleponColor.surface)
154 )
155 .overlay(
156 RoundedRectangle(cornerRadius: 18, style: .continuous)
157 .stroke(KleponColor.divider, lineWidth: 1)
158 )
159 }
160 }
161 #endif
162 }
163
164 private struct SearchResultCard: View {
165 let entry: GuideEntry
166
167 var body: some View {
168 KleponCard {
169 VStack(alignment: .leading, spacing: 10) {
170 HStack {
171 Text(entry.title)
172 .font(KleponTypography.cardTitle)
173 .foregroundStyle(KleponColor.textPrimary)
174
175 Spacer(minLength: 12)
176
177 Label(entry.kind.displayTitle, systemImage: entry.kind.symbolName)
178 .font(KleponTypography.caption)
179 .foregroundStyle(KleponColor.accentWarm)
180 }
181
182 Text(entry.subtitle)
183 .font(KleponTypography.bodySecondary)
184 .foregroundStyle(KleponColor.textSecondary)
185
186 if !entry.tasteNotes.isEmpty {
187 ScrollView(.horizontal, showsIndicators: false) {
188 HStack(spacing: 8) {
189 ForEach(Array(entry.tasteNotes.prefix(3)), id: \.self) { note in
190 KleponChip(title: note)
191 }
192 }
193 }
194 }
195 }
196 }
197 }
198 }