Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b8c966685 | |||
| 0ad448a600 |
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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