Vocab Flashcards — add no-pressure Learn mode alongside the SRS quiz

A Quiz/Learn toggle now sits in the flashcard toolbar (Menu, persisted
via @AppStorage):

  Quiz — unchanged. The VocabSessionQueue SRS path: tap to reveal, rate
  Again/Hard/Good/Easy, requeue, graduation feeds VerbReviewStore.

  Learn — browsing, not testing. Both sides show at once (English +
  Spanish + example sentence + speaker button), Next/Previous step
  through the session pool, looping (wraps at both ends). No rating
  buttons, no SRS writes, no requeue — just repeated exposure.

Both modes draw from the same fetched session pool (due-first + new,
capped 20) and the same lazily-generated example cache. Switching
modes is instant — the quiz queue keeps its state, the learn cursor
keeps its position; they're independent. The header shows the SRS
progress in Quiz and a plain "3 of 20" position in Learn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-16 23:53:22 -05:00
parent 5c0fc8ee2d
commit f14008f96f
@@ -2,20 +2,29 @@ import SwiftUI
import SharedModels
import SwiftData
/// 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.
/// English-first verb flashcards with two modes, switched from the toolbar:
///
/// 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.
/// - **Quiz** the SRS path. `VocabSessionQueue` learning-step queue:
/// tap to reveal, rate Again/Hard/Good/Easy, cards requeue, graduation
/// feeds the long-term `VerbReviewStore` schedule.
/// - **Learn** no-pressure browsing. Both sides shown at once (English +
/// Spanish + example), Next/Previous step through the same session pool
/// on a loop. No rating, no SRS side effects.
struct VocabFlashcardPracticeView: View {
enum Mode: String { case quiz, learn }
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(VerbExampleCache.self) private var exampleCache
@Environment(\.dismiss) private var dismiss
@AppStorage("vocabFlashcardMode") private var modeRaw: String = Mode.quiz.rawValue
/// The fetched session pool (due-first + new, capped). Quiz mode feeds it
/// into `VocabSessionQueue`; Learn mode walks it directly on a loop.
@State private var sessionVerbs: [Verb] = []
@State private var session: VocabSessionQueue?
@State private var learnIndex: Int = 0
@State private var revealed: Bool = false
@State private var exampleByVerbId: [Int: VerbExample] = [:]
@State private var generatingExampleForVerbId: Int? = nil
@@ -23,16 +32,30 @@ struct VocabFlashcardPracticeView: View {
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentVerb: Verb? { session?.current?.verb }
private var mode: Mode {
get { Mode(rawValue: modeRaw) ?? .quiz }
nonmutating set { modeRaw = newValue.rawValue }
}
private var currentVerb: Verb? {
switch mode {
case .quiz:
return session?.current?.verb
case .learn:
guard !sessionVerbs.isEmpty else { return nil }
return sessionVerbs[learnIndex % sessionVerbs.count]
}
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
progressBar
if let verb = currentVerb {
cardBody(verb)
} else {
completionView
headerBar
switch mode {
case .quiz:
quizContent
case .learn:
learnContent
}
}
.padding()
@@ -40,42 +63,82 @@ struct VocabFlashcardPracticeView: View {
}
.navigationTitle("Vocab Flashcards")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Picker("Mode", selection: Binding(
get: { mode },
set: { mode = $0 }
)) {
Label("Quiz (SRS)", systemImage: "checklist").tag(Mode.quiz)
Label("Learn", systemImage: "book").tag(Mode.learn)
}
} label: {
Label(
mode == .learn ? "Learn" : "Quiz",
systemImage: mode == .learn ? "book" : "checklist"
)
}
}
}
.onAppear(perform: loadIfNeeded)
.onChange(of: modeRaw) { _, _ in
revealed = false
primeExampleForCurrent()
}
.animation(.smooth, value: revealed)
.animation(.smooth, value: currentVerb?.id)
}
// MARK: - Progress
// MARK: - Header
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
Text(progressLabel)
.font(.caption)
.foregroundStyle(.secondary)
@ViewBuilder
private var headerBar: some View {
switch mode {
case .quiz:
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0)
.tint(.purple)
Text(quizProgressLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
case .learn:
if sessionVerbs.isEmpty {
Text("No verbs match the levels enabled in Settings")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
}
private var progressLabel: String {
private var quizProgressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Card
// MARK: - Quiz mode
@ViewBuilder
private func cardBody(_ verb: Verb) -> some View {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
private var quizContent: some View {
if let verb = currentVerb {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
revealedContent(verb)
if revealed {
quizRevealed(verb)
} else {
tapToReveal
}
} else {
tapToReveal
completionView
}
}
@@ -97,53 +160,14 @@ struct VocabFlashcardPracticeView: View {
}
}
private func revealedContent(_ verb: Verb) -> some View {
private func quizRevealed(_ verb: Verb) -> some View {
VStack(spacing: 18) {
HStack(spacing: 12) {
Text(verb.infinitive)
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
Button {
speech.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title3)
.padding(10)
}
.glassEffect(in: .circle)
.accessibilityLabel("Say it out loud")
}
spanishRow(verb)
exampleBlock(for: verb)
ratingButtons
}
}
@ViewBuilder
private func exampleBlock(for verb: Verb) -> some View {
if let example = exampleByVerbId[verb.id] {
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish).font(.subheadline).italic()
Text(example.english).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingExampleForVerbId == verb.id {
HStack(spacing: 8) {
ProgressView()
Text("Generating example…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
@@ -171,8 +195,6 @@ struct VocabFlashcardPracticeView: View {
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
@@ -217,12 +239,109 @@ struct VocabFlashcardPracticeView: View {
return "No verbs are due right now. Study Again to review anyway."
}
// MARK: - Learn mode
@ViewBuilder
private var learnContent: some View {
if let verb = currentVerb {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
spanishRow(verb)
exampleBlock(for: verb)
HStack(spacing: 12) {
Button {
learnStep(-1)
} label: {
Label("Previous", systemImage: "chevron.left")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(.secondary)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
Button {
learnStep(1)
} label: {
Label("Next", systemImage: "chevron.right")
.labelStyle(.titleAndIcon)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(.purple)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
} else {
ContentUnavailableView(
"Nothing to Learn",
systemImage: "book",
description: Text("Enable some verb levels in Settings.")
)
.padding(.top, 40)
}
}
private func learnStep(_ delta: Int) {
guard !sessionVerbs.isEmpty else { return }
let count = sessionVerbs.count
learnIndex = ((learnIndex + delta) % count + count) % count
primeExampleForCurrent()
}
// MARK: - Shared card pieces
private func spanishRow(_ verb: Verb) -> some View {
HStack(spacing: 12) {
Text(verb.infinitive)
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
Button {
speech.speak(verb.infinitive)
} label: {
Image(systemName: "speaker.wave.2.fill")
.font(.title3)
.padding(10)
}
.glassEffect(in: .circle)
.accessibilityLabel("Say it out loud")
}
}
@ViewBuilder
private func exampleBlock(for verb: Verb) -> some View {
if let example = exampleByVerbId[verb.id] {
VStack(alignment: .leading, spacing: 4) {
Text(example.spanish).font(.subheadline).italic()
Text(example.english).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
} else if generatingExampleForVerbId == verb.id {
HStack(spacing: 8) {
ProgressView()
Text("Generating example…")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: verbs)
guard sessionVerbs.isEmpty else { return }
sessionVerbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: sessionVerbs)
primeExampleForCurrent()
}