diff --git a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift index 7d51e0f..c153546 100644 --- a/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/Vocab/VocabFlashcardPracticeView.swift @@ -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() }