import SwiftUI import SharedModels import FoundationModels struct BookReaderView: View { let chapter: BookChapter @Environment(DictionaryService.self) private var dictionary @State private var selectedWord: WordAnnotation? @State private var showEnglish = false @State private var lookupCache: [String: WordAnnotation] = [:] private var paragraphsES: [String] { chapter.paragraphsES() } private var paragraphsEN: [String] { chapter.paragraphsEN() } var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 18) { Text(chapter.title) .font(.title2.bold()) .padding(.bottom, 4) ForEach(Array(paragraphsES.enumerated()), id: \.offset) { index, paragraph in if showEnglish { Text(translation(for: index)) .font(.body) .foregroundStyle(.secondary) } else { TappableParagraph(text: paragraph, cache: lookupCache) { word in handleTap(word: word, paragraph: paragraph) } } } } .padding() .adaptiveContainer(maxWidth: 800) } .navigationTitle("Chapter \(chapter.number)") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { withAnimation { showEnglish.toggle() } } label: { Image(systemName: showEnglish ? "character.book.closed.fill.he" : "character.book.closed") .symbolRenderingMode(.hierarchical) } .accessibilityLabel(showEnglish ? "Show Spanish" : "Show English") } } .sheet(item: $selectedWord) { word in WordDetailSheet(word: word) .presentationDetents([.height(220)]) } } private func translation(for index: Int) -> String { guard index < paragraphsEN.count else { return "" } let en = paragraphsEN[index] return en.isEmpty ? "[translation unavailable]" : en } private func handleTap(word: String, paragraph: String) { let cleaned = cleanWord(word) if cleaned.isEmpty { return } if let cached = lookupCache[cleaned] { selectedWord = cached return } if let entry = dictionary.lookup(cleaned) { let annotation = WordAnnotation( word: cleaned, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech ) lookupCache[cleaned] = annotation selectedWord = annotation return } selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Looking up...", partOfSpeech: "") Task { do { let annotation = try await WordLookup.lookup(word: cleaned, inContext: paragraph) lookupCache[cleaned] = annotation selectedWord = annotation } catch { selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Lookup unavailable", partOfSpeech: "") } } } private func cleanWord(_ word: String) -> String { word.lowercased() .trimmingCharacters(in: .punctuationCharacters) .trimmingCharacters(in: .whitespaces) } } // MARK: - Tappable paragraph private struct TappableParagraph: View { let text: String let cache: [String: WordAnnotation] let onTap: (String) -> Void var body: some View { let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init) FlowLayout(spacing: 0) { ForEach(Array(words.enumerated()), id: \.offset) { _, word in WordButton(word: word, onTap: onTap) } } .accessibilityElement(children: .combine) } } private struct WordButton: View { let word: String let onTap: (String) -> Void var body: some View { Button { onTap(word) } label: { Text(word + " ") .font(.body) .foregroundStyle(.primary) } .buttonStyle(.plain) } } // MARK: - Flow layout private struct FlowLayout: Layout { var spacing: CGFloat = 0 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let rows = computeRows(proposal: proposal, subviews: subviews) var height: CGFloat = 0 for row in rows { height += row.map { $0.height }.max() ?? 0 } height += CGFloat(max(0, rows.count - 1)) * spacing return CGSize(width: proposal.width ?? 0, height: height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let rows = computeRows(proposal: proposal, subviews: subviews) var y = bounds.minY var subviewIndex = 0 for row in rows { var x = bounds.minX let rowHeight = row.map { $0.height }.max() ?? 0 for size in row { subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) x += size.width subviewIndex += 1 } y += rowHeight + spacing } } private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] { let maxWidth = proposal.width ?? .infinity var rows: [[CGSize]] = [[]] var currentWidth: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty { rows.append([]) currentWidth = 0 } rows[rows.count - 1].append(size) currentWidth += size.width } return rows } } // MARK: - Word detail sheet private struct WordDetailSheet: View { let word: WordAnnotation var body: some View { VStack(spacing: 16) { HStack { Text(word.word) .font(.title2.bold()) Spacer() if !word.partOfSpeech.isEmpty { Text(word.partOfSpeech) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.fill.tertiary, in: Capsule()) } } Divider() if word.english == "Looking up..." { HStack(spacing: 8) { ProgressView() Text("Looking up word...") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) } else { VStack(alignment: .leading, spacing: 8) { if !word.baseForm.isEmpty && word.baseForm != word.word { HStack { Text("Base form:") .font(.subheadline) .foregroundStyle(.secondary) Text(word.baseForm) .font(.subheadline.weight(.semibold)) .italic() } } if !word.english.isEmpty { HStack { Text("English:") .font(.subheadline) .foregroundStyle(.secondary) Text(word.english) .font(.subheadline.weight(.semibold)) } } } .frame(maxWidth: .infinity, alignment: .leading) } Spacer() } .padding() } } // MARK: - On-demand word lookup (matches StoryReaderView's WordLookup) @MainActor private enum WordLookup { @Generable struct WordInfo { @Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)") var baseForm: String @Guide(description: "English translation") var english: String @Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other") var partOfSpeech: String } static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation { let session = LanguageModelSession(instructions: """ You are a Spanish dictionary. Given a word and the sentence it appears in, \ provide its base form, English translation, and part of speech. """) let response = try await session.respond( to: "Word: \"\(word)\" in sentence: \"\(sentence)\"", generating: WordInfo.self ) let info = response.content return WordAnnotation( word: word, baseForm: info.baseForm, english: info.english, partOfSpeech: info.partOfSpeech ) } }