ac84b22977
Opening a book chapter froze the app in an infinite render loop. Root
cause: the books screens used the eager `NavigationLink { destination }`
form inside `List`/`LazyVStack`. That form keeps the destination view
structurally parented to the source row, so `BookReaderView`'s ScrollView
got trapped inside a `List` row — a row sizes to intrinsic height, a
ScrollView has none, so the two never converge and re-measure forever.
Switch the whole books navigation chain to value-based navigation:
- practiceHomeView, BookLibraryView, BookChapterListView use
NavigationLink(value:).
- PracticeView's NavigationStack declares the BooksRoute, Book, and
BookChapter destinations once, at the stack root (mixing eager and
value-based pushes in one path caused pushed screens to pop back).
- BookReaderView is built from just a BookChapter; it resolves its Book
by slug via @Query.
Also:
- BookChapter gains a stored paragraphCount so the chapter list no longer
decodes the full paragraph JSON on every render (bookDataVersion -> 6
to re-seed).
- BookSpeechController builds its AVSpeechSynthesizer lazily.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
435 lines
15 KiB
Swift
435 lines
15 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
import FoundationModels
|
|
|
|
struct BookReaderView: View {
|
|
let chapter: BookChapter
|
|
|
|
/// The book this chapter belongs to, resolved by slug — used for the
|
|
/// pre-computed glossary. A @Query is safe here because the reader is
|
|
/// built lazily by `navigationDestination`: one instance, when opened.
|
|
@Query private var bookMatches: [Book]
|
|
private var book: Book? { bookMatches.first }
|
|
|
|
@Environment(DictionaryService.self) private var dictionary
|
|
@State private var speech = BookSpeechController()
|
|
@State private var selectedWord: WordAnnotation?
|
|
@State private var showEnglish = false
|
|
@State private var showVoicePicker = false
|
|
@State private var wasReadingBeforeTap = false
|
|
@State private var lookupCache: [String: WordAnnotation] = [:]
|
|
/// The book's pre-computed glossary, decoded once on appear.
|
|
@State private var glossary: [String: WordGloss] = [:]
|
|
|
|
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
|
|
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
|
|
|
|
init(chapter: BookChapter) {
|
|
self.chapter = chapter
|
|
let slug = chapter.bookSlug
|
|
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
|
|
}
|
|
|
|
private var paragraphsES: [String] { chapter.paragraphsES() }
|
|
private var paragraphsEN: [String] { chapter.paragraphsEN() }
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 18) {
|
|
Text(chapter.title)
|
|
.font(.title2.bold())
|
|
.padding(.bottom, 4)
|
|
.id(-1)
|
|
|
|
ForEach(Array(paragraphsES.enumerated()), id: \.offset) { index, paragraph in
|
|
paragraphView(index: index, paragraph: paragraph)
|
|
.id(index)
|
|
}
|
|
}
|
|
.padding()
|
|
.adaptiveContainer(maxWidth: 800)
|
|
}
|
|
.onChange(of: speech.currentParagraphIndex) { _, newIndex in
|
|
guard let newIndex else { return }
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
proxy.scrollTo(newIndex, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Chapter \(chapter.number)")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
Button {
|
|
showVoicePicker = true
|
|
} label: {
|
|
Image(systemName: "waveform.circle")
|
|
.symbolRenderingMode(.hierarchical)
|
|
}
|
|
.accessibilityLabel("Voice & speed")
|
|
|
|
Button {
|
|
toggleReadAloud()
|
|
} label: {
|
|
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(.indigo)
|
|
}
|
|
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
|
|
|
|
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, onDismiss: handleSheetDismiss) { word in
|
|
WordDetailSheet(word: word)
|
|
.presentationDetents([.height(220)])
|
|
}
|
|
.sheet(isPresented: $showVoicePicker) {
|
|
BookVoicePickerSheet(voiceIdentifier: voiceBinding, rate: rateBinding)
|
|
}
|
|
.onAppear {
|
|
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
|
|
speech.rate = Float(storedRate)
|
|
if glossary.isEmpty {
|
|
glossary = book?.glossary() ?? [:]
|
|
}
|
|
}
|
|
.onDisappear {
|
|
speech.stop()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func paragraphView(index: Int, paragraph: String) -> some View {
|
|
if showEnglish {
|
|
Text(translation(for: index))
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
TappableParagraph(
|
|
text: paragraph,
|
|
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
|
|
) { word in
|
|
handleTap(word: word, paragraph: paragraph)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func translation(for index: Int) -> String {
|
|
guard index < paragraphsEN.count else { return "" }
|
|
let en = paragraphsEN[index]
|
|
return en.isEmpty ? "[translation unavailable]" : en
|
|
}
|
|
|
|
// MARK: - Read-along controls
|
|
|
|
private func toggleReadAloud() {
|
|
if speech.isReading {
|
|
speech.stop()
|
|
} else {
|
|
// Start from the first non-vocab paragraph at or after the topmost
|
|
// visible one. For V1 we start from the chapter top — adding
|
|
// "start from visible paragraph" would need a scroll-position
|
|
// observer, which isn't worth the complexity yet.
|
|
speech.start(paragraphs: paragraphsES)
|
|
}
|
|
}
|
|
|
|
private var voiceBinding: Binding<String?> {
|
|
Binding(
|
|
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
|
|
set: { newValue in
|
|
storedVoiceId = newValue ?? ""
|
|
speech.voiceIdentifier = newValue
|
|
}
|
|
)
|
|
}
|
|
|
|
private var rateBinding: Binding<Float> {
|
|
Binding(
|
|
get: { Float(storedRate) },
|
|
set: { newValue in
|
|
storedRate = Double(newValue)
|
|
speech.rate = newValue
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Word tap → definition
|
|
|
|
private func handleTap(word: String, paragraph: String) {
|
|
let cleaned = cleanWord(word)
|
|
if cleaned.isEmpty { return }
|
|
|
|
// If reading aloud, pause immediately. Remember so we can resume when
|
|
// the user dismisses the definition sheet.
|
|
if speech.isReading, !speech.isPaused {
|
|
speech.pause()
|
|
wasReadingBeforeTap = true
|
|
}
|
|
|
|
// Fall-through chain, best source first. Whichever resource answers,
|
|
// the popup names it so a curated glossary hit reads differently from
|
|
// a best-effort on-device LLM guess.
|
|
if let cached = lookupCache[cleaned] {
|
|
selectedWord = cached
|
|
return
|
|
}
|
|
if let gloss = glossary[cleaned] {
|
|
let annotation = WordAnnotation(
|
|
word: cleaned,
|
|
baseForm: gloss.baseForm,
|
|
english: gloss.english,
|
|
partOfSpeech: gloss.partOfSpeech,
|
|
source: "Book glossary"
|
|
)
|
|
lookupCache[cleaned] = annotation
|
|
selectedWord = annotation
|
|
return
|
|
}
|
|
if let entry = dictionary.lookup(cleaned) {
|
|
let annotation = WordAnnotation(
|
|
word: cleaned,
|
|
baseForm: entry.baseForm,
|
|
english: entry.english,
|
|
partOfSpeech: entry.partOfSpeech,
|
|
source: "Dictionary"
|
|
)
|
|
lookupCache[cleaned] = annotation
|
|
selectedWord = annotation
|
|
return
|
|
}
|
|
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Looking up...", partOfSpeech: "")
|
|
Task {
|
|
do {
|
|
var annotation = try await WordLookup.lookup(word: cleaned, inContext: paragraph)
|
|
annotation.source = "AI guess"
|
|
lookupCache[cleaned] = annotation
|
|
selectedWord = annotation
|
|
} catch {
|
|
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Lookup unavailable", partOfSpeech: "")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleSheetDismiss() {
|
|
guard wasReadingBeforeTap else { return }
|
|
wasReadingBeforeTap = false
|
|
speech.resume()
|
|
}
|
|
|
|
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 highlightedWordIndex: Int?
|
|
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) { idx, word in
|
|
WordButton(word: word, isHighlighted: idx == highlightedWordIndex, onTap: onTap)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
private struct WordButton: View {
|
|
let word: String
|
|
let isHighlighted: Bool
|
|
let onTap: (String) -> Void
|
|
|
|
var body: some View {
|
|
Button {
|
|
onTap(word)
|
|
} label: {
|
|
Text(word + " ")
|
|
.font(.body)
|
|
.foregroundStyle(.primary)
|
|
.padding(.horizontal, isHighlighted ? 2 : 0)
|
|
.padding(.vertical, 1)
|
|
.background(
|
|
isHighlighted
|
|
? Color.yellow.opacity(0.35)
|
|
: Color.clear,
|
|
in: RoundedRectangle(cornerRadius: 4)
|
|
)
|
|
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
|
|
}
|
|
.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()
|
|
|
|
if !word.source.isEmpty {
|
|
Text(sourceLabel)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
private var sourceLabel: String {
|
|
word.source == "AI guess"
|
|
? "AI guess · on-device estimate, may be approximate"
|
|
: "Source: \(word.source)"
|
|
}
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
}
|