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:
@@ -14,6 +14,7 @@
|
|||||||
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
|
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
|
||||||
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
|
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
|
||||||
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.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 */; };
|
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
|
||||||
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
|
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
|
||||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
FF3475931F1AD16054741E65 /* BookChapterListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -367,6 +369,7 @@
|
|||||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
|
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
|
||||||
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
|
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
|
||||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
|
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
|
||||||
|
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -799,6 +802,7 @@
|
|||||||
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
|
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
|
||||||
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
|
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
|
||||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
|
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
|
||||||
|
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 5–8 cards later
|
||||||
|
/// Hard → reappears 7–10 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 }
|
||||||
|
|
||||||
|
/// 0–1, 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 SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// English-first verb flashcard. Pool = all verbs whose `level` is enabled
|
/// English-first verb flashcard, driven by `VocabSessionQueue` — an in-session
|
||||||
/// in Settings (UserProgress.selectedVerbLevels) — the same filter that
|
/// learning-step queue. Front: verb.english. Tap to reveal verb.infinitive, a
|
||||||
/// drives the conjugation practice modes.
|
/// lazy-generated example sentence, and SRS rating buttons.
|
||||||
///
|
///
|
||||||
/// Front: verb.english (e.g. "to run"). Tap to reveal verb.infinitive,
|
/// Again/Hard requeue the card a few cards later; Good moves it toward the end;
|
||||||
/// an AI-generated illustration, a lazy-generated example sentence,
|
/// a second Good or an Easy graduates it. The long-term SM-2 schedule
|
||||||
/// and SRS rating buttons.
|
/// (VerbReviewStore) is updated only when a card graduates.
|
||||||
struct VocabFlashcardPracticeView: View {
|
struct VocabFlashcardPracticeView: View {
|
||||||
@Environment(\.modelContext) private var localContext
|
@Environment(\.modelContext) private var localContext
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Environment(VerbExampleCache.self) private var exampleCache
|
@Environment(VerbExampleCache.self) private var exampleCache
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var verbs: [Verb] = []
|
@State private var session: VocabSessionQueue?
|
||||||
@State private var index: Int = 0
|
|
||||||
@State private var revealed: Bool = false
|
@State private var revealed: Bool = false
|
||||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||||
@State private var generatingExampleForVerbId: Int? = nil
|
@State private var generatingExampleForVerbId: Int? = nil
|
||||||
@@ -24,10 +23,7 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
|
|
||||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
private var currentVerb: Verb? {
|
private var currentVerb: Verb? { session?.current?.verb }
|
||||||
guard index < verbs.count else { return nil }
|
|
||||||
return verbs[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -46,23 +42,25 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear(perform: loadIfNeeded)
|
.onAppear(perform: loadIfNeeded)
|
||||||
.animation(.smooth, value: revealed)
|
.animation(.smooth, value: revealed)
|
||||||
.animation(.smooth, value: index)
|
.animation(.smooth, value: currentVerb?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Progress
|
// MARK: - Progress
|
||||||
|
|
||||||
private var progressBar: some View {
|
private var progressBar: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
|
ProgressView(value: session?.progress ?? 0)
|
||||||
.tint(.purple)
|
.tint(.purple)
|
||||||
Text(verbs.isEmpty ? noPoolMessage : "\(min(index + 1, verbs.count)) of \(verbs.count)")
|
Text(progressLabel)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var noPoolMessage: String {
|
private var progressLabel: String {
|
||||||
"No verbs match the levels enabled in Settings"
|
guard let session else { return "Loading…" }
|
||||||
|
if session.isComplete { return "Done" }
|
||||||
|
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Card
|
// MARK: - Card
|
||||||
@@ -152,17 +150,17 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ratingButton("Again", color: .red, quality: .again)
|
ratingButton("Again", color: .red, rating: .again)
|
||||||
ratingButton("Hard", color: .orange, quality: .hard)
|
ratingButton("Hard", color: .orange, rating: .hard)
|
||||||
ratingButton("Good", color: .green, quality: .good)
|
ratingButton("Good", color: .green, rating: .good)
|
||||||
ratingButton("Easy", color: .blue, quality: .easy)
|
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 {
|
Button {
|
||||||
rateAndAdvance(quality)
|
answer(rating)
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
@@ -180,24 +178,67 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 56))
|
.font(.system(size: 56))
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
Text("Session Complete")
|
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
Text("\(verbs.count) verbs reviewed")
|
Text(completionDetail)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.purple)
|
.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(.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
|
// MARK: - Logic
|
||||||
|
|
||||||
private func loadIfNeeded() {
|
private func loadIfNeeded() {
|
||||||
guard verbs.isEmpty else { return }
|
guard session == nil else { return }
|
||||||
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
|
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()
|
primeExampleForCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,12 +246,10 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
guard let verb = currentVerb else { return }
|
guard let verb = currentVerb else { return }
|
||||||
if exampleByVerbId[verb.id] != nil { return }
|
if exampleByVerbId[verb.id] != nil { return }
|
||||||
|
|
||||||
// Cache hit?
|
|
||||||
if let cached = exampleCache.examples(for: verb.id)?.first {
|
if let cached = exampleCache.examples(for: verb.id)?.first {
|
||||||
exampleByVerbId[verb.id] = cached
|
exampleByVerbId[verb.id] = cached
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Otherwise lazy-generate (no blocking on tap-to-reveal).
|
|
||||||
guard VerbExampleGenerator.isAvailable else { return }
|
guard VerbExampleGenerator.isAvailable else { return }
|
||||||
generatingExampleForVerbId = verb.id
|
generatingExampleForVerbId = verb.id
|
||||||
let verbId = verb.id
|
let verbId = verb.id
|
||||||
@@ -220,10 +259,6 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
|
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
|
||||||
Task {
|
Task {
|
||||||
do {
|
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(
|
let examples = try await VerbExampleGenerator.generate(
|
||||||
verbInfinitive: infinitive,
|
verbInfinitive: infinitive,
|
||||||
verbEnglish: english,
|
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 SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// English-first verb multiple choice. Pool = verbs whose `level` is enabled
|
/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options
|
||||||
/// in Settings (UserProgress.selectedVerbLevels). 4 options shown, 1 correct
|
/// (1 correct + 3 random distractors from the session pool). After answering:
|
||||||
/// + 3 random distractors from the same pool. After answer: reveal correct/
|
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
|
||||||
/// incorrect, the verb infinitive, an AI illustration, an example sentence,
|
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
|
||||||
/// and SRS rating buttons.
|
|
||||||
struct VocabMultipleChoicePracticeView: View {
|
struct VocabMultipleChoicePracticeView: View {
|
||||||
@Environment(\.modelContext) private var localContext
|
@Environment(\.modelContext) private var localContext
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Environment(VerbExampleCache.self) private var exampleCache
|
@Environment(VerbExampleCache.self) private var exampleCache
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var verbs: [Verb] = []
|
@State private var session: VocabSessionQueue?
|
||||||
@State private var index: Int = 0
|
@State private var distractorPool: [Verb] = []
|
||||||
@State private var options: [Verb] = []
|
@State private var options: [Verb] = []
|
||||||
@State private var selectedOption: Verb? = nil
|
@State private var selectedOption: Verb? = nil
|
||||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||||
@@ -23,10 +22,7 @@ struct VocabMultipleChoicePracticeView: View {
|
|||||||
|
|
||||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
private var currentVerb: Verb? {
|
private var currentVerb: Verb? { session?.current?.verb }
|
||||||
guard index < verbs.count else { return nil }
|
|
||||||
return verbs[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -45,21 +41,27 @@ struct VocabMultipleChoicePracticeView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear(perform: loadIfNeeded)
|
.onAppear(perform: loadIfNeeded)
|
||||||
.animation(.smooth, value: selectedOption?.id)
|
.animation(.smooth, value: selectedOption?.id)
|
||||||
.animation(.smooth, value: index)
|
.animation(.smooth, value: currentVerb?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Progress
|
// MARK: - Progress
|
||||||
|
|
||||||
private var progressBar: some View {
|
private var progressBar: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
|
ProgressView(value: session?.progress ?? 0)
|
||||||
.tint(.purple)
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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
|
// MARK: - Question
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -157,17 +159,17 @@ struct VocabMultipleChoicePracticeView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ratingButton("Again", color: .red, quality: .again)
|
ratingButton("Again", color: .red, rating: .again)
|
||||||
ratingButton("Hard", color: .orange, quality: .hard)
|
ratingButton("Hard", color: .orange, rating: .hard)
|
||||||
ratingButton("Good", color: .green, quality: .good)
|
ratingButton("Good", color: .green, rating: .good)
|
||||||
ratingButton("Easy", color: .blue, quality: .easy)
|
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 {
|
Button {
|
||||||
rateAndAdvance(quality)
|
answer(rating)
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
@@ -185,35 +187,81 @@ struct VocabMultipleChoicePracticeView: View {
|
|||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 56))
|
.font(.system(size: 56))
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
Text("Session Complete")
|
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
Text("\(verbs.count) verbs reviewed")
|
Text(completionDetail)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.purple)
|
.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(.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
|
// MARK: - Logic
|
||||||
|
|
||||||
private func loadIfNeeded() {
|
private func loadIfNeeded() {
|
||||||
guard verbs.isEmpty else { return }
|
guard session == nil else { return }
|
||||||
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
|
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||||
|
distractorPool = verbs
|
||||||
|
session = VocabSessionQueue(verbs: verbs)
|
||||||
|
prepareOptions()
|
||||||
|
primeExampleForCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func studyAgain() {
|
||||||
|
session?.restart()
|
||||||
|
selectedOption = nil
|
||||||
prepareOptions()
|
prepareOptions()
|
||||||
primeExampleForCurrent()
|
primeExampleForCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prepareOptions() {
|
private func prepareOptions() {
|
||||||
guard let verb = currentVerb else { options = []; return }
|
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))
|
let distractors = Array(candidates.shuffled().prefix(3))
|
||||||
options = ([verb] + distractors).shuffled()
|
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() {
|
private func primeExampleForCurrent() {
|
||||||
guard let verb = currentVerb else { return }
|
guard let verb = currentVerb else { return }
|
||||||
if exampleByVerbId[verb.id] != nil { 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user