Compare commits
4 Commits
aab64116b3
...
4b8c966685
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8c966685 | |||
| 0ad448a600 | |||
| 32395bac5d | |||
| b97da5e85e |
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user