Vocab Practice — proper SRS session queue with in-session learning steps

Vocab Flashcards / Multiple Choice were a flat linear walk through a
shuffled list — rating a card "Hard" or "Again" did nothing to bring it
back. Replaced with a real two-layer SRS, matching how Anki separates
in-session learning steps from the long-term schedule.

VocabSessionQueue (new) — the in-session layer. Position-based learning
steps:
  - Again → card reappears 5–8 cards later (state: learning)
  - Hard  → reappears 7–10 cards later (state: learning)
  - Good  → first time: → review state, reappears 16–24 cards later
            already in review: graduates, leaves the session
  - Easy  → graduates immediately
A card you keep failing keeps cycling until you mark it Good twice or
Easy once. answer() returns a ReviewQuality only on graduation — that's
the single rating handed to the long-term VerbReviewStore, so
intermediate Again/Hard presses no longer thrash the cross-session
SM-2 schedule.

VocabVerbPool.sessionVerbs (rewritten) — due-first ordering + a 20-card
session cap. Overdue verbs (per VerbReviewCard.dueDate) come first,
most-overdue leading; then never-reviewed verbs by frequency rank.
Not-yet-due verbs are intentionally skipped — that's the SRS schedule
doing its job. A single sitting is now bounded instead of a 100+ card
slog.

Study Again — the completion screen gets a "Study Again" button that
rebuilds the queue from the same verb set (re-shuffled), so you can run
the whole set again after finishing.

Progress display switched from "1 of 110" to "N learned · M to go",
which reflects the live queue as cards requeue and graduate.

Both vocab views now share VocabSessionQueue + VocabVerbPool; the queue
struct is pure value-type logic, easy to reason about and test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-15 15:44:43 -05:00
parent d61f9e50b1
commit 5c0fc8ee2d
4 changed files with 324 additions and 117 deletions
@@ -14,6 +14,7 @@
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; };
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */; };
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
@@ -270,6 +271,7 @@
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; };
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
FF3475931F1AD16054741E65 /* BookChapterListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -367,6 +369,7 @@
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -799,6 +802,7 @@
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -0,0 +1,175 @@
import Foundation
import SharedModels
import SwiftData
/// In-session "learning steps" queue for vocab practice the short-term
/// scheduling layer that sits on top of the cross-session SM-2 schedule.
///
/// A card is requeued a relative number of positions ahead based on the
/// rating, mirroring Anki's learning steps but position-based instead of
/// time-based:
/// Again reappears 58 cards later
/// Hard reappears 710 cards later
/// Good first time: advances to `review`, reappears ~20 cards later
/// already in review: graduates (leaves the session)
/// Easy graduates immediately
///
/// `answer` returns a `ReviewQuality` only when the card graduates that's
/// the single rating fed to the long-term `VerbReviewStore`. Intermediate
/// Again/Hard presses don't touch the cross-session schedule.
struct VocabSessionQueue {
enum CardState {
case new // never answered this session
case learning // answered Again/Hard at least once
case review // answered Good once, one confirmation pass to go
}
enum Rating {
case again, hard, good, easy
}
struct Entry: Identifiable {
let id = UUID()
let verb: Verb
var state: CardState
}
private(set) var queue: [Entry]
private(set) var learnedCount: Int = 0
private let originalVerbs: [Verb]
init(verbs: [Verb]) {
originalVerbs = verbs
queue = verbs.map { Entry(verb: $0, state: .new) }
}
// MARK: - State
var current: Entry? { queue.first }
var isComplete: Bool { queue.isEmpty }
var remainingCount: Int { queue.count }
/// 01, climbs as cards graduate. Requeuing a card lowers it slightly but
/// it always trends to 1 as the session drains.
var progress: Double {
let total = learnedCount + queue.count
return total == 0 ? 1 : Double(learnedCount) / Double(total)
}
// MARK: - Answering
/// Apply a rating to the current card. Returns the `ReviewQuality` to
/// record in the long-term SRS *iff* the card graduated; nil if it was
/// requeued for more in-session practice.
@discardableResult
mutating func answer(_ rating: Rating) -> ReviewQuality? {
guard !queue.isEmpty else { return nil }
var entry = queue.removeFirst()
switch rating {
case .again:
entry.state = .learning
insert(entry, offset: Int.random(in: 5...8))
return nil
case .hard:
entry.state = .learning
insert(entry, offset: Int.random(in: 7...10))
return nil
case .good:
if entry.state == .review {
learnedCount += 1
return .good
}
entry.state = .review
insert(entry, offset: Int.random(in: 16...24))
return nil
case .easy:
learnedCount += 1
return .easy
}
}
/// Rebuild the session from the same verb set (re-shuffled) "Study Again".
mutating func restart() {
queue = originalVerbs.shuffled().map { Entry(verb: $0, state: .new) }
learnedCount = 0
}
// MARK: - Private
/// Insert `entry` `offset` positions from the front, i.e. `offset` other
/// cards will be shown before it reappears. Clamps to the queue's end.
private mutating func insert(_ entry: Entry, offset: Int) {
let idx = min(queue.count, offset)
queue.insert(entry, at: idx)
}
}
// MARK: - Session verb pool
/// Builds a vocab-practice session: level-filtered verbs ordered due-first
/// (per the cross-session `VerbReviewCard` schedule) and capped so a single
/// sitting is bounded proper SRS behaviour rather than a 100+ card slog.
enum VocabVerbPool {
/// Maximum verbs in one session. Overdue cards are pulled first, then new
/// (never-reviewed) verbs fill the rest.
static let sessionCardLimit = 20
static func sessionVerbs(
localContext: ModelContext,
cloudContext: ModelContext
) -> [Verb] {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let levels = Set(progress.selectedVerbLevels.map(\.rawValue))
let store = ReferenceStore(context: localContext)
let pool = levels.isEmpty
? store.fetchVerbs()
: store.fetchVerbs(selectedLevels: levels)
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
let cardByVerbId = Dictionary(
reviewCards.map { ($0.verbId, $0) },
uniquingKeysWith: { existing, _ in existing }
)
let now = Date()
var due: [(verb: Verb, dueDate: Date)] = []
var fresh: [Verb] = []
for verb in pool {
if let card = cardByVerbId[verb.id] {
if card.dueDate <= now {
due.append((verb, card.dueDate))
}
// Not yet due intentionally skipped; that's the SRS schedule.
} else {
fresh.append(verb)
}
}
// Most-overdue first, then new verbs (lower rank = more common first).
due.sort { $0.dueDate < $1.dueDate }
fresh.sort { $0.rank < $1.rank }
let ordered = due.map(\.verb) + fresh
return Array(ordered.prefix(sessionCardLimit))
}
}
/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema
/// requires exactly 6 examples, so callers must pass 6 distinct tense IDs.
enum VocabExampleTenseIds {
static let canonical: [String] = [
TenseID.ind_presente.rawValue,
TenseID.ind_preterito.rawValue,
TenseID.ind_imperfecto.rawValue,
TenseID.ind_futuro.rawValue,
TenseID.subj_presente.rawValue,
TenseID.imp_afirmativo.rawValue,
]
}
@@ -2,21 +2,20 @@ import SwiftUI
import SharedModels
import SwiftData
/// English-first verb flashcard. Pool = all verbs whose `level` is enabled
/// in Settings (UserProgress.selectedVerbLevels) the same filter that
/// drives the conjugation practice modes.
/// English-first verb flashcard, driven by `VocabSessionQueue` an in-session
/// learning-step queue. Front: verb.english. Tap to reveal verb.infinitive, a
/// lazy-generated example sentence, and SRS rating buttons.
///
/// Front: verb.english (e.g. "to run"). Tap to reveal verb.infinitive,
/// an AI-generated illustration, a lazy-generated example sentence,
/// and SRS rating buttons.
/// Again/Hard requeue the card a few cards later; Good moves it toward the end;
/// a second Good or an Easy graduates it. The long-term SM-2 schedule
/// (VerbReviewStore) is updated only when a card graduates.
struct VocabFlashcardPracticeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@State private var verbs: [Verb] = []
@State private var index: Int = 0
@State private var session: VocabSessionQueue?
@State private var revealed: Bool = false
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingExampleForVerbId: Int? = nil
@@ -24,10 +23,7 @@ struct VocabFlashcardPracticeView: View {
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentVerb: Verb? {
guard index < verbs.count else { return nil }
return verbs[index]
}
private var currentVerb: Verb? { session?.current?.verb }
var body: some View {
ScrollView {
@@ -46,23 +42,25 @@ struct VocabFlashcardPracticeView: View {
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: revealed)
.animation(.smooth, value: index)
.animation(.smooth, value: currentVerb?.id)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
Text(verbs.isEmpty ? noPoolMessage : "\(min(index + 1, verbs.count)) of \(verbs.count)")
Text(progressLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var noPoolMessage: String {
"No verbs match the levels enabled in Settings"
private var progressLabel: String {
guard let session else { return "Loading" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Card
@@ -152,17 +150,17 @@ struct VocabFlashcardPracticeView: View {
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
ratingButton("Again", color: .red, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
Button {
rateAndAdvance(quality)
answer(rating)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
@@ -180,24 +178,67 @@ struct VocabFlashcardPracticeView: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text("Session Complete")
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text("\(verbs.count) verbs reviewed")
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Done") { dismiss() }
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button {
studyAgain()
} label: {
Label("Study Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
.padding(.top, 12)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
return "\(learned) verb\(learned == 1 ? "" : "s") learned"
}
return "No verbs are due right now. Study Again to review anyway."
}
// MARK: - Logic
private func loadIfNeeded() {
guard verbs.isEmpty else { return }
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
guard session == nil else { return }
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: verbs)
primeExampleForCurrent()
}
private func studyAgain() {
session?.restart()
revealed = false
primeExampleForCurrent()
}
private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil
if let graduation {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
}
withAnimation(.smooth) { revealed = false }
primeExampleForCurrent()
}
@@ -205,12 +246,10 @@ struct VocabFlashcardPracticeView: View {
guard let verb = currentVerb else { return }
if exampleByVerbId[verb.id] != nil { return }
// Cache hit?
if let cached = exampleCache.examples(for: verb.id)?.first {
exampleByVerbId[verb.id] = cached
return
}
// Otherwise lazy-generate (no blocking on tap-to-reveal).
guard VerbExampleGenerator.isAvailable else { return }
generatingExampleForVerbId = verb.id
let verbId = verb.id
@@ -220,10 +259,6 @@ struct VocabFlashcardPracticeView: View {
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
Task {
do {
// The generator's @Generable schema requires exactly 6
// examples; pass the canonical 6-tense set used by
// VerbDetailView, then pick the present-tense one to
// show on the card.
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
@@ -243,50 +278,4 @@ struct VocabFlashcardPracticeView: View {
}
}
}
private func rateAndAdvance(_ quality: ReviewQuality) {
guard let verb = currentVerb else { return }
VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
withAnimation(.smooth) {
revealed = false
index += 1
}
primeExampleForCurrent()
}
}
/// Canonical 6-tense set used by `VerbExampleGenerator`. Its `@Generable`
/// schema requires exactly 6 examples; callers must pass 6 distinct tense
/// IDs so the model has a unique slot for each generated example.
enum VocabExampleTenseIds {
static let canonical: [String] = [
TenseID.ind_presente.rawValue,
TenseID.ind_preterito.rawValue,
TenseID.ind_imperfecto.rawValue,
TenseID.ind_futuro.rawValue,
TenseID.subj_presente.rawValue,
TenseID.imp_afirmativo.rawValue,
]
}
// MARK: - Pool helper
/// Shared verb-pool fetch used by both vocab flashcard and vocab MC.
/// Reads `UserProgress.selectedVerbLevels` from the cloud context and
/// filters the local Verb table by those levels the exact same path
/// `PracticeSessionService` already uses.
enum VocabVerbPool {
static func fetch(localContext: ModelContext, cloudContext: ModelContext) -> [Verb] {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let levels = progress.selectedVerbLevels.map(\.rawValue)
let levelSet = Set(levels)
let store = ReferenceStore(context: localContext)
let pool: [Verb]
if levelSet.isEmpty {
pool = store.fetchVerbs()
} else {
pool = store.fetchVerbs(selectedLevels: levelSet)
}
return pool.shuffled()
}
}
@@ -2,19 +2,18 @@ import SwiftUI
import SharedModels
import SwiftData
/// English-first verb multiple choice. Pool = verbs whose `level` is enabled
/// in Settings (UserProgress.selectedVerbLevels). 4 options shown, 1 correct
/// + 3 random distractors from the same pool. After answer: reveal correct/
/// incorrect, the verb infinitive, an AI illustration, an example sentence,
/// and SRS rating buttons.
/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options
/// (1 correct + 3 random distractors from the session pool). After answering:
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
struct VocabMultipleChoicePracticeView: View {
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@State private var verbs: [Verb] = []
@State private var index: Int = 0
@State private var session: VocabSessionQueue?
@State private var distractorPool: [Verb] = []
@State private var options: [Verb] = []
@State private var selectedOption: Verb? = nil
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@@ -23,10 +22,7 @@ struct VocabMultipleChoicePracticeView: View {
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentVerb: Verb? {
guard index < verbs.count else { return nil }
return verbs[index]
}
private var currentVerb: Verb? { session?.current?.verb }
var body: some View {
ScrollView {
@@ -45,21 +41,27 @@ struct VocabMultipleChoicePracticeView: View {
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: index)
.animation(.smooth, value: currentVerb?.id)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
Text(verbs.isEmpty ? "No verbs match the levels enabled in Settings" : "\(min(index + 1, verbs.count)) of \(verbs.count)")
Text(progressLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var progressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Question
@ViewBuilder
@@ -157,17 +159,17 @@ struct VocabMultipleChoicePracticeView: View {
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
ratingButton("Again", color: .red, rating: .again)
ratingButton("Hard", color: .orange, rating: .hard)
ratingButton("Good", color: .green, rating: .good)
ratingButton("Easy", color: .blue, rating: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
Button {
rateAndAdvance(quality)
answer(rating)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
@@ -185,35 +187,81 @@ struct VocabMultipleChoicePracticeView: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text("Session Complete")
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text("\(verbs.count) verbs reviewed")
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Done") { dismiss() }
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button {
studyAgain()
} label: {
Label("Study Again", systemImage: "arrow.clockwise")
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.purple)
.padding(.top, 12)
Button("Done") { dismiss() }
.font(.subheadline.weight(.semibold))
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
}
.padding(.top, 12)
}
.padding(.top, 60)
.padding(.horizontal, 24)
}
private var completionDetail: String {
let learned = session?.learnedCount ?? 0
if learned > 0 {
return "\(learned) verb\(learned == 1 ? "" : "s") learned"
}
return "No verbs are due right now. Study Again to review anyway."
}
// MARK: - Logic
private func loadIfNeeded() {
guard verbs.isEmpty else { return }
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
guard session == nil else { return }
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
distractorPool = verbs
session = VocabSessionQueue(verbs: verbs)
prepareOptions()
primeExampleForCurrent()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
private func prepareOptions() {
guard let verb = currentVerb else { options = []; return }
let candidates = verbs.filter { $0.id != verb.id }
let candidates = distractorPool.filter { $0.id != verb.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([verb] + distractors).shuffled()
}
private func answer(_ rating: VocabSessionQueue.Rating) {
guard let verbId = currentVerb?.id else { return }
let graduation = session?.answer(rating) ?? nil
if let graduation {
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
}
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
private func primeExampleForCurrent() {
guard let verb = currentVerb else { return }
if exampleByVerbId[verb.id] != nil { return }
@@ -247,13 +295,4 @@ struct VocabMultipleChoicePracticeView: View {
}
}
}
private func rateAndAdvance(_ quality: ReviewQuality) {
guard let verb = currentVerb else { return }
VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
index += 1
selectedOption = nil
prepareOptions()
primeExampleForCurrent()
}
}