Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8c966685 | |||
| 0ad448a600 | |||
| 32395bac5d | |||
| b97da5e85e |
@@ -93,6 +93,7 @@
|
||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
||||
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 */; };
|
||||
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */; };
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -416,6 +418,7 @@
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */,
|
||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||
);
|
||||
path = Services;
|
||||
@@ -924,6 +927,7 @@
|
||||
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */,
|
||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -43,6 +43,12 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
let paragraphIndex: Int
|
||||
let text: String
|
||||
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() {
|
||||
@@ -55,17 +61,40 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
/// `currentParagraphIndex` are positions in the original `paragraphs`
|
||||
/// array — vocab lines are skipped internally but the visible index space
|
||||
/// 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()
|
||||
configureAudioSession()
|
||||
|
||||
var entries: [QueueEntry] = []
|
||||
for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
|
||||
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(
|
||||
paragraphIndex: idx,
|
||||
text: p,
|
||||
wordRanges: Self.wordRanges(in: p)
|
||||
wordRanges: Self.wordRanges(in: p),
|
||||
wordIndexOffset: 0
|
||||
))
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
@@ -189,8 +218,11 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
let idx = entry.wordRanges.firstIndex {
|
||||
$0.lowerBound <= lower && lower < $0.upperBound
|
||||
}
|
||||
if let idx, idx != currentWordIndex {
|
||||
currentWordIndex = idx
|
||||
if let idx {
|
||||
let reported = entry.wordIndexOffset + idx
|
||||
if reported != currentWordIndex {
|
||||
currentWordIndex = reported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,21 @@ enum LexemePool {
|
||||
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(
|
||||
partOfSpeech: String,
|
||||
drillMode: String,
|
||||
@@ -179,8 +194,15 @@ enum LexemePool {
|
||||
return lhs.baseForm < rhs.baseForm
|
||||
}
|
||||
|
||||
let ordered = due.map(\.lexeme) + fresh
|
||||
return Array(ordered.prefix(sessionCardLimit(for: partOfSpeech)))
|
||||
// Reviews take priority: due cards fill the session first, then up to
|
||||
// `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)`,
|
||||
|
||||
@@ -142,6 +142,15 @@ enum VocabVerbPool {
|
||||
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(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
@@ -178,8 +187,14 @@ enum VocabVerbPool {
|
||||
due.sort { $0.dueDate < $1.dueDate }
|
||||
fresh.sort { $0.rank < $1.rank }
|
||||
|
||||
let ordered = due.map(\.verb) + fresh
|
||||
return Array(ordered.prefix(sessionCardLimit))
|
||||
// Reviews take priority: due verbs fill the session first, then up to
|
||||
// `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
|
||||
|
||||
@@ -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] = [:]
|
||||
/// The book's pre-computed glossary, decoded once on appear.
|
||||
@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("bookReaderRate") private var storedRate: Double = 0.45
|
||||
@AppStorage("bookReaderRate") private var storedRate: Double = 0.50
|
||||
|
||||
init(chapter: BookChapter) {
|
||||
self.chapter = chapter
|
||||
@@ -70,14 +79,24 @@ struct BookReaderView: View {
|
||||
}
|
||||
.accessibilityLabel("Voice & speed")
|
||||
|
||||
if speech.isReading {
|
||||
Button {
|
||||
speech.stop()
|
||||
} label: {
|
||||
Image(systemName: "stop.circle")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.accessibilityLabel("Stop reading")
|
||||
}
|
||||
|
||||
Button {
|
||||
toggleReadAloud()
|
||||
} label: {
|
||||
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
|
||||
Image(systemName: playButtonIcon)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
|
||||
.accessibilityLabel(playButtonLabel)
|
||||
|
||||
Button {
|
||||
withAnimation { showEnglish.toggle() }
|
||||
@@ -105,6 +124,7 @@ struct BookReaderView: View {
|
||||
.onDisappear {
|
||||
speech.stop()
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: startAnchor)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -116,10 +136,11 @@ struct BookReaderView: View {
|
||||
} else {
|
||||
TappableParagraph(
|
||||
text: paragraph,
|
||||
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
|
||||
) { word in
|
||||
handleTap(word: word, paragraph: paragraph)
|
||||
}
|
||||
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil,
|
||||
startWordIndex: startAnchor?.paragraphIndex == index ? startAnchor?.wordIndex : nil,
|
||||
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
|
||||
|
||||
/// 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() {
|
||||
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 {
|
||||
// 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 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?> {
|
||||
Binding(
|
||||
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
|
||||
@@ -238,13 +286,21 @@ struct BookReaderView: View {
|
||||
private struct TappableParagraph: View {
|
||||
let text: String
|
||||
let highlightedWordIndex: Int?
|
||||
let startWordIndex: Int?
|
||||
let onTap: (String) -> Void
|
||||
let onLongPress: (Int) -> 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)
|
||||
WordButton(
|
||||
word: word,
|
||||
isHighlighted: idx == highlightedWordIndex,
|
||||
isStartAnchor: idx == startWordIndex,
|
||||
onTap: onTap,
|
||||
onLongPress: { onLongPress(idx) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
@@ -254,7 +310,9 @@ private struct TappableParagraph: View {
|
||||
private struct WordButton: View {
|
||||
let word: String
|
||||
let isHighlighted: Bool
|
||||
let isStartAnchor: Bool
|
||||
let onTap: (String) -> Void
|
||||
let onLongPress: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
@@ -263,17 +321,26 @@ private struct WordButton: View {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, isHighlighted ? 2 : 0)
|
||||
.padding(.horizontal, (isHighlighted || isStartAnchor) ? 2 : 0)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
isHighlighted
|
||||
? Color.yellow.opacity(0.35)
|
||||
: Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 4)
|
||||
)
|
||||
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 4))
|
||||
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
|
||||
.animation(.easeInOut(duration: 0.15), value: isStartAnchor)
|
||||
}
|
||||
.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 {
|
||||
Section("Speed") {
|
||||
Picker("Speed", selection: $rate) {
|
||||
Text("Slow").tag(Float(0.40))
|
||||
Text("Normal").tag(Float(0.50))
|
||||
Text("Fast").tag(Float(0.55))
|
||||
Text("0.5×").tag(Float(0.30))
|
||||
Text("0.75×").tag(Float(0.40))
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
|
||||
struct SettingsView: View {
|
||||
@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 dailyGoal: Double = 50
|
||||
@@ -16,6 +18,19 @@ struct SettingsView: View {
|
||||
@AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20
|
||||
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 irregularCategories: [IrregularSpan.SpanCategory] = [
|
||||
.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.")
|
||||
}
|
||||
|
||||
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 {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
@@ -68,6 +93,7 @@ struct SettingsView: View {
|
||||
guard let progress else { return }
|
||||
progress.setLevelEnabled(level, enabled: enabled)
|
||||
saveProgress()
|
||||
refreshMetrics()
|
||||
}
|
||||
))
|
||||
}
|
||||
@@ -77,6 +103,14 @@ struct SettingsView: View {
|
||||
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 {
|
||||
ForEach(LexemeLevel.allCases, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
@@ -87,6 +121,7 @@ struct SettingsView: View {
|
||||
guard let progress else { return }
|
||||
progress.setLexemeLevelEnabled(level, enabled: enabled)
|
||||
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.")
|
||||
}
|
||||
|
||||
Section {
|
||||
statusRows(nounStatus)
|
||||
} header: {
|
||||
Text("Noun Status")
|
||||
} footer: {
|
||||
Text("Counts reflect only the vocabulary levels enabled above.")
|
||||
}
|
||||
|
||||
Section("Adjective Status") {
|
||||
statusRows(adjectiveStatus)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
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() {
|
||||
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress = resolved
|
||||
dailyGoal = Double(resolved.dailyGoal)
|
||||
showVosotros = resolved.showVosotros
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user