Files
Spanish/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift
T
Trey T f14008f96f 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>
2026-05-16 23:53:22 -05:00

401 lines
14 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
/// English-first verb flashcards with two modes, switched from the toolbar:
///
/// - **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
@State private var speech = SpeechService()
private var cloudContext: ModelContext { cloudModelContextProvider() }
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) {
headerBar
switch mode {
case .quiz:
quizContent
case .learn:
learnContent
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.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: - Header
@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 quizProgressLabel: String {
guard let session else { return "Loading…" }
if session.isComplete { return "Done" }
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
}
// MARK: - Quiz mode
@ViewBuilder
private var quizContent: some View {
if let verb = currentVerb {
Text(verb.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
quizRevealed(verb)
} else {
tapToReveal
}
} else {
completionView
}
}
private var tapToReveal: some View {
VStack(spacing: 8) {
Image(systemName: "hand.tap")
.font(.title)
.foregroundStyle(.secondary)
Text("Tap to reveal")
.font(.headline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.frame(minHeight: 200)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { revealed = true }
}
}
private func quizRevealed(_ verb: Verb) -> some View {
VStack(spacing: 18) {
spanishRow(verb)
exampleBlock(for: verb)
ratingButtons
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
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, rating: VocabSessionQueue.Rating) -> some View {
Button {
answer(rating)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
.font(.title2.bold())
Text(completionDetail)
.font(.subheadline)
.foregroundStyle(.secondary)
.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)
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: - 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 sessionVerbs.isEmpty else { return }
sessionVerbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
session = VocabSessionQueue(verbs: sessionVerbs)
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()
}
private func primeExampleForCurrent() {
guard let verb = currentVerb else { return }
if exampleByVerbId[verb.id] != nil { return }
if let cached = exampleCache.examples(for: verb.id)?.first {
exampleByVerbId[verb.id] = cached
return
}
guard VerbExampleGenerator.isAvailable else { return }
generatingExampleForVerbId = verb.id
let verbId = verb.id
let infinitive = verb.infinitive
let english = verb.english
let formsByTense = ReferenceStore(context: localContext)
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
Task {
do {
let examples = try await VerbExampleGenerator.generate(
verbInfinitive: infinitive,
verbEnglish: english,
tenseIds: VocabExampleTenseIds.canonical,
formsByTense: formsByTense
)
exampleCache.setExamples(examples, for: verbId)
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
if let pick, currentVerb?.id == verbId {
exampleByVerbId[verbId] = pick
}
} catch {
// Silent the example block just stays hidden.
}
if generatingExampleForVerbId == verbId {
generatingExampleForVerbId = nil
}
}
}
}