4 Commits

Author SHA1 Message Date
Trey T 4b8c966685 Merge book-reader read-aloud features into vocab-session-tuning 2026-06-09 10:44:22 -05:00
Trey T 0ad448a600 Vocab sessions — new-word throttle + per-type status metrics
- New "New Words Per Session" setting (verbs/nouns/adjectives, 0–20 or
  All, default 10). Session builders now fill with due reviews first, then
  add fresh words only up to that throttle in the leftover room — so reviews
  take priority and new vocab is introduced steadily. Fixes both flashcards
  and multiple choice; Review Learned untouched.
- New per-type word-status metrics in Settings (New / Overdue / Due today /
  Upcoming / Learned + Total), scoped to the enabled levels, shown under the
  Verb Levels and Vocabulary Levels sections. Backed by WordStatusMetrics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:43:42 -05:00
Trey T 32395bac5d Book reader — speed as a 5-option dropdown with multiplier labels
Replace the 3-way segmented speed control with a dropdown menu offering
0.5× / 0.75× / 1× / 1.25× / 1.5×, with evened-out underlying AVSpeech
rates anchored at 1× = 0.50. Align the default saved rate to 0.50 so 1×
is selected on a fresh install (was 0.45, which matched no option).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:20:58 -05:00
Trey T b97da5e85e Book reader — choose read-aloud start + play/pause/resume
- Long-press a word to mark where read-aloud begins (session-only). A
  distinct indigo marker shows the spot; long-pressing it again clears it.
  Play honors the marker on a fresh start.
- BookSpeechController can start mid-paragraph by speaking a substring;
  a per-entry wordIndexOffset keeps word highlighting aligned to the full
  paragraph's coordinates.
- The main button is now Play / Pause / Resume — it resumes in place
  instead of restarting, so pausing, flipping to English and back, then
  resuming continues from the same word. A separate Stop button ends the
  session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:08:07 -05:00
8 changed files with 369 additions and 32 deletions
@@ -93,6 +93,7 @@
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; }; 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; }; 983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
995C466AE3C95A95DB9457A1 /* Beginner_I_W6.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */; }; 995C466AE3C95A95DB9457A1 /* Beginner_I_W6.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */; };
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */; };
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; }; 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; }; 9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; }; A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
@@ -286,6 +287,7 @@
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; }; CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; }; CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; }; CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordStatusMetrics.swift; sourceTree = "<group>"; };
CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W1.pdf; sourceTree = "<group>"; }; CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W1.pdf; sourceTree = "<group>"; };
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; }; CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; }; D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
@@ -416,6 +418,7 @@
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */, 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */, 841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */, D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */, AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
); );
path = Services; path = Services;
@@ -924,6 +927,7 @@
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */, 84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */,
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */, 05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -43,6 +43,12 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
let paragraphIndex: Int let paragraphIndex: Int
let text: String let text: String
let wordRanges: [Range<String.Index>] let wordRanges: [Range<String.Index>]
/// Words skipped at the front of this paragraph when the caller asked to
/// start mid-paragraph. The utterance is built from a substring, so the
/// synth's word indices are local to that substring; add this offset to
/// report a word index in the full paragraph's coordinate space (which
/// is what the view highlights against). 0 for whole-paragraph entries.
let wordIndexOffset: Int
} }
override init() { override init() {
@@ -55,17 +61,40 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
/// `currentParagraphIndex` are positions in the original `paragraphs` /// `currentParagraphIndex` are positions in the original `paragraphs`
/// array vocab lines are skipped internally but the visible index space /// array vocab lines are skipped internally but the visible index space
/// matches what the caller passed. /// matches what the caller passed.
func start(paragraphs: [String], from startIndex: Int = 0) { func start(paragraphs: [String], fromParagraph startIndex: Int = 0, word startWordIndex: Int? = nil) {
stop() stop()
configureAudioSession() configureAudioSession()
var entries: [QueueEntry] = [] var entries: [QueueEntry] = []
for (idx, p) in paragraphs.enumerated() where idx >= startIndex { for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
if Self.isVocabLine(p) { continue } if Self.isVocabLine(p) { continue }
// Apply the word offset only to the first paragraph actually read,
// and only when it's the paragraph the caller pointed at. A skipped
// vocab line at startIndex, word 0, or an out-of-range index all
// fall through to reading the whole paragraph.
if entries.isEmpty,
idx == startIndex,
let startWord = startWordIndex,
startWord > 0 {
let fullRanges = Self.wordRanges(in: p)
if startWord < fullRanges.count {
let substring = String(p[fullRanges[startWord].lowerBound...])
entries.append(QueueEntry(
paragraphIndex: idx,
text: substring,
wordRanges: Self.wordRanges(in: substring),
wordIndexOffset: startWord
))
continue
}
}
entries.append(QueueEntry( entries.append(QueueEntry(
paragraphIndex: idx, paragraphIndex: idx,
text: p, text: p,
wordRanges: Self.wordRanges(in: p) wordRanges: Self.wordRanges(in: p),
wordIndexOffset: 0
)) ))
} }
guard !entries.isEmpty else { return } guard !entries.isEmpty else { return }
@@ -189,8 +218,11 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
let idx = entry.wordRanges.firstIndex { let idx = entry.wordRanges.firstIndex {
$0.lowerBound <= lower && lower < $0.upperBound $0.lowerBound <= lower && lower < $0.upperBound
} }
if let idx, idx != currentWordIndex { if let idx {
currentWordIndex = idx let reported = entry.wordIndexOffset + idx
if reported != currentWordIndex {
currentWordIndex = reported
}
} }
} }
@@ -130,6 +130,21 @@ enum LexemePool {
return stored == 0 ? 20 : stored return stored == 0 ? 20 : stored
} }
/// Max brand-new words to introduce per session for a POS, from its
/// "New words per session" setting. 0 is a valid value (review-only), so
/// "unset" is distinguished from 0 and defaults to 10. 999 means "no
/// throttle" (fill whatever room reviews leave).
static func newWordsPerSession(for partOfSpeech: String) -> Int {
let key: String
switch partOfSpeech {
case "noun": key = "nounNewWordsPerSession"
case "adjective": key = "adjectiveNewWordsPerSession"
default: key = "lexemeNewWordsPerSession"
}
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
return UserDefaults.standard.integer(forKey: key)
}
static func sessionLexemes( static func sessionLexemes(
partOfSpeech: String, partOfSpeech: String,
drillMode: String, drillMode: String,
@@ -179,8 +194,15 @@ enum LexemePool {
return lhs.baseForm < rhs.baseForm return lhs.baseForm < rhs.baseForm
} }
let ordered = due.map(\.lexeme) + fresh // Reviews take priority: due cards fill the session first, then up to
return Array(ordered.prefix(sessionCardLimit(for: partOfSpeech))) // `newMax` fresh words take whatever room is left (Anki-style new-card
// throttle). 999 = no throttle (old behavior: fill the cap with fresh).
let cap = sessionCardLimit(for: partOfSpeech)
let newMax = newWordsPerSession(for: partOfSpeech)
let dueTaken = Array(due.map(\.lexeme).prefix(cap))
let remaining = cap - dueTaken.count
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
return dueTaken + newTaken
} }
/// Lexemes the user has already studied at least once for `(POS, drill)`, /// Lexemes the user has already studied at least once for `(POS, drill)`,
@@ -142,6 +142,15 @@ enum VocabVerbPool {
return stored == 0 ? 20 : stored return stored == 0 ? 20 : stored
} }
/// Max brand-new verbs to introduce per session, from the "New verbs per
/// session" setting. 0 is valid (review-only), so "unset" defaults to 10;
/// 999 means "no throttle". Mirrors `LexemePool.newWordsPerSession`.
static var newWordsPerSession: Int {
let key = "vocabNewWordsPerSession"
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
return UserDefaults.standard.integer(forKey: key)
}
static func sessionVerbs( static func sessionVerbs(
localContext: ModelContext, localContext: ModelContext,
cloudContext: ModelContext cloudContext: ModelContext
@@ -178,8 +187,14 @@ enum VocabVerbPool {
due.sort { $0.dueDate < $1.dueDate } due.sort { $0.dueDate < $1.dueDate }
fresh.sort { $0.rank < $1.rank } fresh.sort { $0.rank < $1.rank }
let ordered = due.map(\.verb) + fresh // Reviews take priority: due verbs fill the session first, then up to
return Array(ordered.prefix(sessionCardLimit)) // `newMax` fresh verbs take whatever room is left. 999 = no throttle.
let cap = sessionCardLimit
let newMax = newWordsPerSession
let dueTaken = Array(due.map(\.verb).prefix(cap))
let remaining = cap - dueTaken.count
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
return dueTaken + newTaken
} }
/// Verbs the user has already studied at least once (have a /// Verbs the user has already studied at least once (have a
@@ -0,0 +1,107 @@
import Foundation
import SharedModels
import SwiftData
/// Status breakdown for a word type, scoped to the levels the user currently
/// has enabled. Buckets are mutually exclusive and sum to `total` (the count
/// of enabled-level words of that type).
struct StatusCounts: Equatable {
var new = 0 // never studied (no review card)
var overdue = 0 // card due before today
var dueToday = 0 // card due today
var upcoming = 0 // scheduled for the future, still maturing (interval < mature)
var learned = 0 // scheduled for the future and mature (interval >= mature)
var total: Int { new + overdue + dueToday + upcoming + learned }
}
/// Counts words by SRS status for the Settings progress readout. Pure counting
/// over the level-scoped reference pool + review cards no mutation.
enum WordStatusMetrics {
/// Anki convention: a card with an interval of three weeks or more is
/// considered "mature" rather than still being learned.
private static let matureIntervalDays = 21
static func verbCounts(
selectedLevels: Set<VerbLevel>,
localContext: ModelContext,
cloudContext: ModelContext
) -> StatusCounts {
let store = ReferenceStore(context: localContext)
let levelStrings = Set(selectedLevels.map(\.rawValue))
// Mirror `VocabVerbPool.sessionVerbs`: an empty selection means "all".
let verbs = levelStrings.isEmpty ? store.fetchVerbs() : store.fetchVerbs(selectedLevels: levelStrings)
let cards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let byId = Dictionary(cards.map { ($0.verbId, $0) }, uniquingKeysWith: { existing, _ in existing })
var counts = StatusCounts()
let calendar = Calendar.current
let now = Date()
for verb in verbs {
if let card = byId[verb.id] {
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
} else {
counts.new += 1
}
}
return counts
}
static func lexemeCounts(
partOfSpeech: String,
drillMode: String = "recall",
selectedLevels: Set<LexemeLevel>,
localContext: ModelContext,
cloudContext: ModelContext
) -> StatusCounts {
let lexDescriptor = FetchDescriptor<Lexeme>(
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
)
let all = (try? localContext.fetch(lexDescriptor)) ?? []
// Scope to the enabled CEFR levels same gate the fresh pool uses.
let pool = all.filter { selectedLevels.contains(LexemeLevel.level(forRank: $0.frequencyRank)) }
let cardDescriptor = FetchDescriptor<LexemeReviewCard>(
predicate: #Predicate<LexemeReviewCard> {
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
}
)
let cards = (try? cloudContext.fetch(cardDescriptor)) ?? []
let byId = Dictionary(cards.map { ($0.lexemeId, $0) }, uniquingKeysWith: { existing, _ in existing })
var counts = StatusCounts()
let calendar = Calendar.current
let now = Date()
for lexeme in pool {
if let card = byId[lexeme.id] {
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
} else {
counts.new += 1
}
}
return counts
}
/// Classify one studied card into a bucket. Due-state takes precedence over
/// maturity: a mature card that's due still counts as overdue/due-today.
private static func bump(
_ counts: inout StatusCounts,
interval: Int,
dueDate: Date,
now: Date,
calendar: Calendar
) {
let today = calendar.startOfDay(for: now)
let dueDay = calendar.startOfDay(for: dueDate)
if dueDay < today {
counts.overdue += 1
} else if dueDay == today {
counts.dueToday += 1
} else if interval >= matureIntervalDays {
counts.learned += 1
} else {
counts.upcoming += 1
}
}
}
@@ -21,9 +21,18 @@ struct BookReaderView: View {
@State private var lookupCache: [String: WordAnnotation] = [:] @State private var lookupCache: [String: WordAnnotation] = [:]
/// The book's pre-computed glossary, decoded once on appear. /// The book's pre-computed glossary, decoded once on appear.
@State private var glossary: [String: WordGloss] = [:] @State private var glossary: [String: WordGloss] = [:]
/// The word long-pressed as the read-aloud start point. Session-only
/// consulted when starting fresh; cleared by long-pressing it again.
@State private var startAnchor: ReadingStart?
/// A chosen read-aloud start location: a word within a paragraph.
private struct ReadingStart: Equatable {
let paragraphIndex: Int
let wordIndex: Int
}
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = "" @AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45 @AppStorage("bookReaderRate") private var storedRate: Double = 0.50
init(chapter: BookChapter) { init(chapter: BookChapter) {
self.chapter = chapter self.chapter = chapter
@@ -70,14 +79,24 @@ struct BookReaderView: View {
} }
.accessibilityLabel("Voice & speed") .accessibilityLabel("Voice & speed")
if speech.isReading {
Button {
speech.stop()
} label: {
Image(systemName: "stop.circle")
.symbolRenderingMode(.hierarchical)
}
.accessibilityLabel("Stop reading")
}
Button { Button {
toggleReadAloud() toggleReadAloud()
} label: { } label: {
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill") Image(systemName: playButtonIcon)
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.foregroundStyle(.indigo) .foregroundStyle(.indigo)
} }
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud") .accessibilityLabel(playButtonLabel)
Button { Button {
withAnimation { showEnglish.toggle() } withAnimation { showEnglish.toggle() }
@@ -105,6 +124,7 @@ struct BookReaderView: View {
.onDisappear { .onDisappear {
speech.stop() speech.stop()
} }
.sensoryFeedback(.selection, trigger: startAnchor)
} }
@ViewBuilder @ViewBuilder
@@ -116,10 +136,11 @@ struct BookReaderView: View {
} else { } else {
TappableParagraph( TappableParagraph(
text: paragraph, text: paragraph,
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil,
) { word in startWordIndex: startAnchor?.paragraphIndex == index ? startAnchor?.wordIndex : nil,
handleTap(word: word, paragraph: paragraph) onTap: { word in handleTap(word: word, paragraph: paragraph) },
} onLongPress: { wordIndex in setStartAnchor(paragraphIndex: index, wordIndex: wordIndex) }
)
} }
} }
@@ -131,18 +152,45 @@ struct BookReaderView: View {
// MARK: - Read-along controls // MARK: - Read-along controls
/// Main read-aloud button: starts, pauses, or resumes it never stops, so
/// the reading position survives pausing (and flipping to the English
/// translation and back). Stopping is a separate button. A fresh start
/// honors the long-pressed `startAnchor` when one is set.
private func toggleReadAloud() { private func toggleReadAloud() {
if speech.isReading { if speech.isReading {
speech.stop() if speech.isPaused {
speech.resume()
} else {
speech.pause()
}
} else if let anchor = startAnchor {
speech.start(
paragraphs: paragraphsES,
fromParagraph: anchor.paragraphIndex,
word: anchor.wordIndex
)
} else { } 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) speech.start(paragraphs: paragraphsES)
} }
} }
private var playButtonIcon: String {
(speech.isReading && !speech.isPaused) ? "pause.circle.fill" : "play.circle.fill"
}
private var playButtonLabel: String {
guard speech.isReading else { return "Read aloud" }
return speech.isPaused ? "Resume" : "Pause"
}
/// Long-press handler: mark this word as the start point, or clear it when
/// the already-marked word is long-pressed again. Doesn't interrupt an
/// active read-aloud the anchor is used on the next fresh start.
private func setStartAnchor(paragraphIndex: Int, wordIndex: Int) {
let candidate = ReadingStart(paragraphIndex: paragraphIndex, wordIndex: wordIndex)
startAnchor = (startAnchor == candidate) ? nil : candidate
}
private var voiceBinding: Binding<String?> { private var voiceBinding: Binding<String?> {
Binding( Binding(
get: { storedVoiceId.isEmpty ? nil : storedVoiceId }, get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
@@ -238,13 +286,21 @@ struct BookReaderView: View {
private struct TappableParagraph: View { private struct TappableParagraph: View {
let text: String let text: String
let highlightedWordIndex: Int? let highlightedWordIndex: Int?
let startWordIndex: Int?
let onTap: (String) -> Void let onTap: (String) -> Void
let onLongPress: (Int) -> Void
var body: some View { var body: some View {
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init) let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
FlowLayout(spacing: 0) { FlowLayout(spacing: 0) {
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in ForEach(Array(words.enumerated()), id: \.offset) { idx, word in
WordButton(word: word, isHighlighted: idx == highlightedWordIndex, onTap: onTap) WordButton(
word: word,
isHighlighted: idx == highlightedWordIndex,
isStartAnchor: idx == startWordIndex,
onTap: onTap,
onLongPress: { onLongPress(idx) }
)
} }
} }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
@@ -254,7 +310,9 @@ private struct TappableParagraph: View {
private struct WordButton: View { private struct WordButton: View {
let word: String let word: String
let isHighlighted: Bool let isHighlighted: Bool
let isStartAnchor: Bool
let onTap: (String) -> Void let onTap: (String) -> Void
let onLongPress: () -> Void
var body: some View { var body: some View {
Button { Button {
@@ -263,17 +321,26 @@ private struct WordButton: View {
Text(word + " ") Text(word + " ")
.font(.body) .font(.body)
.foregroundStyle(.primary) .foregroundStyle(.primary)
.padding(.horizontal, isHighlighted ? 2 : 0) .padding(.horizontal, (isHighlighted || isStartAnchor) ? 2 : 0)
.padding(.vertical, 1) .padding(.vertical, 1)
.background( .background(backgroundColor, in: RoundedRectangle(cornerRadius: 4))
isHighlighted
? Color.yellow.opacity(0.35)
: Color.clear,
in: RoundedRectangle(cornerRadius: 4)
)
.animation(.easeInOut(duration: 0.15), value: isHighlighted) .animation(.easeInOut(duration: 0.15), value: isHighlighted)
.animation(.easeInOut(duration: 0.15), value: isStartAnchor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
// Long-press marks the read-aloud start; the Button's tap still defines
// the word. simultaneousGesture lets both live on the same view.
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.4).onEnded { _ in onLongPress() }
)
}
/// The active spoken word (yellow) takes precedence over the start marker
/// (indigo) so a word that's both reads as "now speaking."
private var backgroundColor: Color {
if isHighlighted { return Color.yellow.opacity(0.35) }
if isStartAnchor { return Color.indigo.opacity(0.20) }
return Color.clear
} }
} }
@@ -42,11 +42,13 @@ struct BookVoicePickerSheet: View {
Form { Form {
Section("Speed") { Section("Speed") {
Picker("Speed", selection: $rate) { Picker("Speed", selection: $rate) {
Text("Slow").tag(Float(0.40)) Text("0.5×").tag(Float(0.30))
Text("Normal").tag(Float(0.50)) Text("0.75×").tag(Float(0.40))
Text("Fast").tag(Float(0.55)) Text("1×").tag(Float(0.50))
Text("1.25×").tag(Float(0.575))
Text("1.5×").tag(Float(0.65))
} }
.pickerStyle(.segmented) .pickerStyle(.menu)
} }
if groups.isEmpty { if groups.isEmpty {
@@ -4,6 +4,8 @@ import SwiftData
struct SettingsView: View { struct SettingsView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
/// Local reference store (verbs, lexemes) needed for the status metrics.
@Environment(\.modelContext) private var localContext
@State private var progress: UserProgress? @State private var progress: UserProgress?
@State private var dailyGoal: Double = 50 @State private var dailyGoal: Double = 50
@@ -16,6 +18,19 @@ struct SettingsView: View {
@AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20 @AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999] private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
/// New (never-studied) words introduced per session, per type. 0 =
/// review-only; 999 = "All" (no throttle).
@AppStorage("vocabNewWordsPerSession") private var vocabNewWordsPerSession: Int = 10
@AppStorage("nounNewWordsPerSession") private var nounNewWordsPerSession: Int = 10
@AppStorage("adjectiveNewWordsPerSession") private var adjectiveNewWordsPerSession: Int = 10
private let newWordsSizes: [Int] = [0, 5, 10, 15, 20, 999]
/// SRS status breakdowns, scoped to the enabled levels. Recomputed on
/// appear and whenever the level toggles change.
@State private var verbStatus = StatusCounts()
@State private var nounStatus = StatusCounts()
@State private var adjectiveStatus = StatusCounts()
private let levels = VerbLevel.allCases private let levels = VerbLevel.allCases
private let irregularCategories: [IrregularSpan.SpanCategory] = [ private let irregularCategories: [IrregularSpan.SpanCategory] = [
.spelling, .stemChange, .uniqueIrregular .spelling, .stemChange, .uniqueIrregular
@@ -58,6 +73,16 @@ struct SettingsView: View {
Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.") Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.")
} }
Section {
newWordsPicker("New verbs per session", selection: $vocabNewWordsPerSession)
newWordsPicker("New nouns per session", selection: $nounNewWordsPerSession)
newWordsPicker("New adjectives per session", selection: $adjectiveNewWordsPerSession)
} header: {
Text("New Words Per Session")
} footer: {
Text("How many brand-new words a session introduces. Overdue reviews are shown first; new words fill whatever room is left, up to this number. 0 = reviews only.")
}
Section { Section {
ForEach(levels, id: \.self) { level in ForEach(levels, id: \.self) { level in
Toggle(level.displayName, isOn: Binding( Toggle(level.displayName, isOn: Binding(
@@ -68,6 +93,7 @@ struct SettingsView: View {
guard let progress else { return } guard let progress else { return }
progress.setLevelEnabled(level, enabled: enabled) progress.setLevelEnabled(level, enabled: enabled)
saveProgress() saveProgress()
refreshMetrics()
} }
)) ))
} }
@@ -77,6 +103,14 @@ struct SettingsView: View {
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.") Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
} }
Section {
statusRows(verbStatus)
} header: {
Text("Verb Status")
} footer: {
Text("Counts reflect only the verb levels enabled above.")
}
Section { Section {
ForEach(LexemeLevel.allCases, id: \.self) { level in ForEach(LexemeLevel.allCases, id: \.self) { level in
Toggle(level.displayName, isOn: Binding( Toggle(level.displayName, isOn: Binding(
@@ -87,6 +121,7 @@ struct SettingsView: View {
guard let progress else { return } guard let progress else { return }
progress.setLexemeLevelEnabled(level, enabled: enabled) progress.setLexemeLevelEnabled(level, enabled: enabled)
saveProgress() saveProgress()
refreshMetrics()
} }
)) ))
} }
@@ -96,6 +131,18 @@ struct SettingsView: View {
Text("Noun and adjective flashcards pull only from the enabled CEFR levels. New first-time installs default to A1 + A2.") Text("Noun and adjective flashcards pull only from the enabled CEFR levels. New first-time installs default to A1 + A2.")
} }
Section {
statusRows(nounStatus)
} header: {
Text("Noun Status")
} footer: {
Text("Counts reflect only the vocabulary levels enabled above.")
}
Section("Adjective Status") {
statusRows(adjectiveStatus)
}
Section { Section {
ForEach(TenseInfo.all) { tense in ForEach(TenseInfo.all) { tense in
Toggle(tense.english, isOn: Binding( Toggle(tense.english, isOn: Binding(
@@ -180,12 +227,53 @@ struct SettingsView: View {
} }
} }
private func newWordsPicker(_ title: String, selection: Binding<Int>) -> some View {
Picker(title, selection: selection) {
ForEach(newWordsSizes, id: \.self) { size in
Text(size == 999 ? "All" : "\(size)").tag(size)
}
}
}
@ViewBuilder
private func statusRows(_ counts: StatusCounts) -> some View {
LabeledContent("New", value: "\(counts.new)")
LabeledContent("Overdue", value: "\(counts.overdue)")
LabeledContent("Due today", value: "\(counts.dueToday)")
LabeledContent("Upcoming", value: "\(counts.upcoming)")
LabeledContent("Learned", value: "\(counts.learned)")
LabeledContent("Total", value: "\(counts.total)")
.foregroundStyle(.secondary)
}
private func loadProgress() { private func loadProgress() {
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext) let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress = resolved progress = resolved
dailyGoal = Double(resolved.dailyGoal) dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros showVosotros = resolved.showVosotros
autoFillStem = resolved.autoFillStem autoFillStem = resolved.autoFillStem
refreshMetrics()
}
private func refreshMetrics() {
guard let progress else { return }
verbStatus = WordStatusMetrics.verbCounts(
selectedLevels: progress.selectedVerbLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
nounStatus = WordStatusMetrics.lexemeCounts(
partOfSpeech: "noun",
selectedLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
adjectiveStatus = WordStatusMetrics.lexemeCounts(
partOfSpeech: "adjective",
selectedLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudModelContext
)
} }
private func saveProgress() { private func saveProgress() {