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:
@@ -2,20 +2,29 @@ import SwiftUI
|
|||||||
import SharedModels
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// English-first verb flashcard, driven by `VocabSessionQueue` — an in-session
|
/// English-first verb flashcards with two modes, switched from the toolbar:
|
||||||
/// learning-step queue. Front: verb.english. Tap to reveal verb.infinitive, a
|
|
||||||
/// lazy-generated example sentence, and SRS rating buttons.
|
|
||||||
///
|
///
|
||||||
/// Again/Hard requeue the card a few cards later; Good moves it toward the end;
|
/// - **Quiz** — the SRS path. `VocabSessionQueue` learning-step queue:
|
||||||
/// a second Good or an Easy graduates it. The long-term SM-2 schedule
|
/// tap to reveal, rate Again/Hard/Good/Easy, cards requeue, graduation
|
||||||
/// (VerbReviewStore) is updated only when a card graduates.
|
/// 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 {
|
struct VocabFlashcardPracticeView: View {
|
||||||
|
enum Mode: String { case quiz, learn }
|
||||||
|
|
||||||
@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
|
||||||
|
|
||||||
|
@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 session: VocabSessionQueue?
|
||||||
|
@State private var learnIndex: 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
|
||||||
@@ -23,16 +32,30 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
|
|
||||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
progressBar
|
headerBar
|
||||||
if let verb = currentVerb {
|
switch mode {
|
||||||
cardBody(verb)
|
case .quiz:
|
||||||
} else {
|
quizContent
|
||||||
completionView
|
case .learn:
|
||||||
|
learnContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -40,42 +63,82 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Vocab Flashcards")
|
.navigationTitle("Vocab Flashcards")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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)
|
.onAppear(perform: loadIfNeeded)
|
||||||
|
.onChange(of: modeRaw) { _, _ in
|
||||||
|
revealed = false
|
||||||
|
primeExampleForCurrent()
|
||||||
|
}
|
||||||
.animation(.smooth, value: revealed)
|
.animation(.smooth, value: revealed)
|
||||||
.animation(.smooth, value: currentVerb?.id)
|
.animation(.smooth, value: currentVerb?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Progress
|
// MARK: - Header
|
||||||
|
|
||||||
private var progressBar: some View {
|
@ViewBuilder
|
||||||
VStack(spacing: 6) {
|
private var headerBar: some View {
|
||||||
ProgressView(value: session?.progress ?? 0)
|
switch mode {
|
||||||
.tint(.purple)
|
case .quiz:
|
||||||
Text(progressLabel)
|
VStack(spacing: 6) {
|
||||||
.font(.caption)
|
ProgressView(value: session?.progress ?? 0)
|
||||||
.foregroundStyle(.secondary)
|
.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…" }
|
guard let session else { return "Loading…" }
|
||||||
if session.isComplete { return "Done" }
|
if session.isComplete { return "Done" }
|
||||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Card
|
// MARK: - Quiz mode
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func cardBody(_ verb: Verb) -> some View {
|
private var quizContent: some View {
|
||||||
Text(verb.english)
|
if let verb = currentVerb {
|
||||||
.font(.largeTitle.weight(.bold))
|
Text(verb.english)
|
||||||
.multilineTextAlignment(.center)
|
.font(.largeTitle.weight(.bold))
|
||||||
.padding(.top, 12)
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
if revealed {
|
if revealed {
|
||||||
revealedContent(verb)
|
quizRevealed(verb)
|
||||||
|
} else {
|
||||||
|
tapToReveal
|
||||||
|
}
|
||||||
} else {
|
} 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) {
|
VStack(spacing: 18) {
|
||||||
HStack(spacing: 12) {
|
spanishRow(verb)
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
exampleBlock(for: verb)
|
exampleBlock(for: verb)
|
||||||
|
|
||||||
ratingButtons
|
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 {
|
private var ratingButtons: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Text("How well did you know it?")
|
Text("How well did you know it?")
|
||||||
@@ -171,8 +195,6 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Completion
|
|
||||||
|
|
||||||
private var completionView: some View {
|
private var completionView: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
@@ -217,12 +239,109 @@ struct VocabFlashcardPracticeView: View {
|
|||||||
return "No verbs are due right now. Study Again to review anyway."
|
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
|
// MARK: - Logic
|
||||||
|
|
||||||
private func loadIfNeeded() {
|
private func loadIfNeeded() {
|
||||||
guard session == nil else { return }
|
guard sessionVerbs.isEmpty else { return }
|
||||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
sessionVerbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||||
session = VocabSessionQueue(verbs: verbs)
|
session = VocabSessionQueue(verbs: sessionVerbs)
|
||||||
primeExampleForCurrent()
|
primeExampleForCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user