development
swift 397 lines 14.5 KB
Raw
1 import SwiftUI
2
3 struct AskSheetView: View {
4 let entry: GuideEntry
5 let initialQuestion: String?
6
7 @Environment(\.dismiss) private var dismiss
8 @EnvironmentObject private var appState: AppState
9 @EnvironmentObject private var guideEngine: OndeGuideEngine
10
11 @State private var question = ""
12 @State private var answerHistory: [AnswerCard] = []
13 @State private var isLoading = false
14
15 private var latestAnswerCard: AnswerCard? {
16 answerHistory.last
17 }
18
19 private var earlierAnswerCards: [AnswerCard] {
20 Array(answerHistory.dropLast())
21 }
22
23 private func sourceTitles(for card: AnswerCard) -> [String] {
24 card.sourceEntryIDs.compactMap { appState.contentRepository.entry(id: $0)?.title }
25 }
26
27 var body: some View {
28 ScrollView {
29 VStack(alignment: .leading, spacing: 20) {
30 #if os(macOS)
31 HStack {
32 Spacer()
33
34 Button("Close") {
35 dismiss()
36 }
37 #if os(macOS)
38 .keyboardShortcut(.cancelAction)
39 #endif
40 }
41 #endif
42
43 KleponCard {
44 VStack(alignment: .leading, spacing: 12) {
45 Text("Ask privately about \(entry.title)")
46 .font(KleponTypography.screenTitle)
47 .foregroundStyle(KleponColor.textPrimary)
48
49 Text(
50 "Ask a natural follow-up, keep it short, and let the guide stay in the background."
51 )
52 .font(KleponTypography.body)
53 .foregroundStyle(KleponColor.textSecondary)
54
55 Text(guideEngine.availability.title)
56 .font(KleponTypography.caption)
57 .foregroundStyle(
58 guideEngine.availability.isFailure
59 ? KleponColor.highlight : KleponColor.accentWarm)
60
61 Text(guideEngine.availability.detail)
62 .font(KleponTypography.bodySecondary)
63 .foregroundStyle(KleponColor.textSecondary)
64 }
65 }
66
67 VStack(alignment: .leading, spacing: 12) {
68 SectionHeader("Suggested questions")
69
70 LazyVGrid(
71 columns: [GridItem(.adaptive(minimum: 140), spacing: 10)], spacing: 10
72 ) {
73 ForEach(entry.suggestedQuestions, id: \.self) { suggestion in
74 Button {
75 question = suggestion
76 submit(question: suggestion)
77 } label: {
78 Text(suggestion)
79 .font(KleponTypography.bodySecondary)
80 .multilineTextAlignment(.leading)
81 .frame(maxWidth: .infinity, alignment: .leading)
82 .padding(12)
83 .background(
84 RoundedRectangle(cornerRadius: 18, style: .continuous)
85 .fill(KleponColor.surfaceSecondary)
86 )
87 .foregroundStyle(KleponColor.textPrimary)
88 }
89 .kleponInteractiveButtonStyle()
90 .disabled(isLoading)
91 }
92 }
93 }
94
95 VStack(alignment: .leading, spacing: 12) {
96 SectionHeader("Your question")
97
98 #if os(tvOS)
99 TextField(
100 "Ask about taste, ingredients, or what to try next", text: $question
101 )
102 .padding(.horizontal, 14)
103 .padding(.vertical, 12)
104 .background(
105 RoundedRectangle(cornerRadius: 18, style: .continuous)
106 .fill(KleponColor.surfaceSecondary)
107 )
108 #else
109 TextField(
110 "Ask about taste, ingredients, or what to try next", text: $question,
111 axis: .vertical
112 )
113 .textFieldStyle(.roundedBorder)
114 .lineLimit(2...5)
115 #endif
116
117 KleponActionButton(
118 title: isLoading ? "Thinking" : "Get answer",
119 systemImage: "lock",
120 isLoading: isLoading,
121 isDisabled: question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
122 ) {
123 submit(question: question)
124 }
125
126 if guideEngine.availability.isFailure {
127 KleponActionButton(
128 title: "Retry private guide",
129 systemImage: "arrow.clockwise",
130 tone: .secondary
131 ) {
132 Task {
133 await guideEngine.prepareIfNeeded(forceReload: true)
134 }
135 }
136 }
137 }
138
139 if isLoading {
140 AnswerLoadingCard()
141 }
142
143 if let latestAnswerCard {
144 AnswerCardView(
145 card: latestAnswerCard,
146 sourceTitles: sourceTitles(for: latestAnswerCard),
147 isInteractionDisabled: isLoading,
148 onFollowUp: { followUp in
149 question = followUp
150 submit(question: followUp)
151 }
152 )
153 }
154
155 if !earlierAnswerCards.isEmpty {
156 VStack(alignment: .leading, spacing: 12) {
157 SectionHeader("Earlier in this sheet")
158
159 ForEach(Array(earlierAnswerCards.reversed()), id: \.id) { card in
160 AnswerCardView(
161 card: card,
162 sourceTitles: sourceTitles(for: card),
163 isInteractionDisabled: isLoading,
164 onFollowUp: { followUp in
165 question = followUp
166 submit(question: followUp)
167 }
168 )
169 }
170 }
171 }
172
173 KleponCard {
174 Text(
175 "Private answers use the on-device guide when it is ready. If it is not ready yet, Klepon falls back to the curated notes already bundled in the app."
176 )
177 .font(KleponTypography.caption)
178 .foregroundStyle(KleponColor.textSecondary)
179 }
180 }
181 .padding(.horizontal, 20)
182 .padding(.vertical, 16)
183 }
184 .background(KleponColor.background.ignoresSafeArea())
185 .navigationTitle("Ask privately")
186 .kleponInlineNavigationTitle()
187 .toolbar {
188 ToolbarItem(placement: .kleponPrimaryAction) {
189 Button("Close") {
190 dismiss()
191 }
192 #if os(macOS)
193 .keyboardShortcut(.cancelAction)
194 #endif
195 .tint(KleponColor.accent)
196 }
197 }
198 .task {
199 guard let initialQuestion, question.isEmpty else { return }
200 question = initialQuestion
201 submit(question: initialQuestion)
202 }
203 }
204
205 private func submit(question: String) {
206 let trimmedQuestion = question.trimmingCharacters(in: .whitespacesAndNewlines)
207 guard !trimmedQuestion.isEmpty, !isLoading else { return }
208
209 isLoading = true
210
211 Task {
212 let result = await appState.answerService.answer(
213 question: trimmedQuestion,
214 about: entry,
215 previousCards: answerHistory
216 )
217 answerHistory.append(result)
218 if answerHistory.count > 3 {
219 answerHistory = Array(answerHistory.suffix(3))
220 }
221 isLoading = false
222 }
223 }
224 }
225
226 private struct AnswerCardView: View {
227 let card: AnswerCard
228 let sourceTitles: [String]
229 let isInteractionDisabled: Bool
230 let onFollowUp: (String) -> Void
231
232 var body: some View {
233 KleponCard {
234 VStack(alignment: .leading, spacing: 14) {
235 HStack {
236 Text(card.headline)
237 .font(KleponTypography.cardTitle)
238 .foregroundStyle(KleponColor.textPrimary)
239
240 Spacer(minLength: 12)
241
242 if card.isGeneratedOnDevice {
243 KleponChip(title: "On this device", icon: "lock")
244 } else {
245 KleponChip(title: "Curated fallback", icon: "book")
246 }
247 }
248
249 VStack(alignment: .leading, spacing: 14) {
250 ForEach(parseSections(from: card.body), id: \.title) { section in
251 VStack(alignment: .leading, spacing: 6) {
252 Text(section.title)
253 .font(KleponTypography.caption)
254 .foregroundStyle(KleponColor.accentWarm)
255
256 Text(section.body)
257 .font(KleponTypography.body)
258 .foregroundStyle(KleponColor.textSecondary)
259 }
260 }
261 }
262
263 if !card.highlights.isEmpty {
264 FlexibleAnswerTagLayout(items: card.highlights)
265 }
266
267 if !sourceTitles.isEmpty {
268 VStack(alignment: .leading, spacing: 8) {
269 Text("Grounded in")
270 .font(KleponTypography.caption)
271 .foregroundStyle(KleponColor.accentWarm)
272
273 FlexibleAnswerTagLayout(items: sourceTitles)
274 }
275 }
276
277 if !card.followUpSuggestions.isEmpty {
278 VStack(alignment: .leading, spacing: 10) {
279 Text("Try a follow-up")
280 .font(KleponTypography.caption)
281 .foregroundStyle(KleponColor.accentWarm)
282
283 ForEach(card.followUpSuggestions, id: \.self) { followUp in
284 Button {
285 onFollowUp(followUp)
286 } label: {
287 Text(followUp)
288 .font(KleponTypography.bodySecondary)
289 .frame(maxWidth: .infinity, alignment: .leading)
290 .padding(12)
291 .background(
292 RoundedRectangle(cornerRadius: 16, style: .continuous)
293 .fill(KleponColor.surfaceSecondary)
294 )
295 .foregroundStyle(KleponColor.textPrimary)
296 }
297 .kleponInteractiveButtonStyle()
298 .disabled(isInteractionDisabled)
299 }
300 }
301 }
302 }
303 }
304 }
305 }
306
307 private struct AnswerLoadingCard: View {
308 var body: some View {
309 KleponCard {
310 VStack(alignment: .leading, spacing: 12) {
311 Text("Preparing your answer")
312 .font(KleponTypography.cardTitle)
313 .foregroundStyle(KleponColor.textPrimary)
314
315 Text(
316 "Klepon is grounding your question against the local guide and keeping the response short."
317 )
318 .font(KleponTypography.bodySecondary)
319 .foregroundStyle(KleponColor.textSecondary)
320
321 ProgressView()
322 .tint(KleponColor.accent)
323 }
324 }
325 }
326 }
327
328 private struct AnswerSection {
329 let title: String
330 let body: String
331 }
332
333 private func parseSections(from body: String) -> [AnswerSection] {
334 let headings = ["Short answer", "What to expect", "Try next"]
335 let lines =
336 body
337 .components(separatedBy: .newlines)
338 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
339 .filter { !$0.isEmpty }
340
341 guard !lines.isEmpty else {
342 return [AnswerSection(title: "Answer", body: body)]
343 }
344
345 var sections: [AnswerSection] = []
346 var currentTitle: String?
347 var currentBodyLines: [String] = []
348
349 func flushSection() {
350 guard let currentTitle, !currentBodyLines.isEmpty else { return }
351 sections.append(
352 AnswerSection(title: currentTitle, body: currentBodyLines.joined(separator: " ")))
353 }
354
355 for line in lines {
356 if let heading = headings.first(where: { heading in
357 let loweredLine = line.lowercased()
358 let loweredHeading = heading.lowercased()
359 return loweredLine == loweredHeading || loweredLine.hasPrefix(loweredHeading + ":")
360 }) {
361 flushSection()
362 currentTitle = heading
363 if let range = line.range(of: ":") {
364 let remainder = String(line[range.upperBound...]).trimmingCharacters(
365 in: .whitespaces)
366 currentBodyLines = remainder.isEmpty ? [] : [remainder]
367 } else {
368 currentBodyLines = []
369 }
370 } else {
371 currentBodyLines.append(line)
372 }
373 }
374
375 flushSection()
376
377 if sections.isEmpty {
378 return [AnswerSection(title: "Answer", body: body)]
379 }
380
381 return sections
382 }
383
384 private struct FlexibleAnswerTagLayout: View {
385 let items: [String]
386
387 var body: some View {
388 LazyVGrid(
389 columns: [GridItem(.adaptive(minimum: 90), spacing: 10)], alignment: .leading,
390 spacing: 10
391 ) {
392 ForEach(items, id: \.self) { item in
393 KleponChip(title: item)
394 }
395 }
396 }
397 }