Files
Spanish/Conjuga/Conjuga/Views/Practice/Vocab/NounMultipleChoicePracticeView.swift
T
Trey T aab64116b3 Vocab study — per-type session sizes + Review Learned multiple choice
- Settings: split the single session-size picker into separate Verbs /
  Nouns / Adjectives pickers. Nouns and adjectives previously shared one
  hidden limit; they now use nounSessionCardLimit / adjectiveSessionCardLimit.
- LexemePool.sessionCardLimit is now per part-of-speech.
- Multiple-choice views (verb/noun/adjective) gained a kind param so
  Review Learned can run as multiple choice, not just flashcards. The
  cram pass drives the in-session queue only and leaves the long-term
  SRS schedule untouched.
- PracticeView: each section now offers Review Learned — Flashcards and
  Review Learned — Multiple Choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:54:10 -05:00

279 lines
9.7 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
/// English-first noun multiple choice non-verb analog of
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
/// noun pool; 4 options (1 correct + 3 random distractors from the session).
/// After answering: reveal feedback, the answer with its article (la taza /
/// el problema), example sentence when present, and Again/Hard/Good/Easy
/// rating which drives the `LexemeReviewStore` schedule.
struct NounMultipleChoicePracticeView: View {
var kind: LexemeSessionKind = .standard
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var session: LexemeSessionQueue?
@State private var distractorPool: [Lexeme] = []
@State private var options: [Lexeme] = []
@State private var selectedOption: Lexeme? = nil
private static let drillMode = "recall"
private var cloudContext: ModelContext { cloudModelContextProvider() }
private var currentLexeme: Lexeme? { session?.current?.lexeme }
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let lexeme = currentLexeme {
questionBody(lexeme)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Noun Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: currentLexeme?.id)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: session?.progress ?? 0).tint(.teal)
Text(progressLabel).font(.caption).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
@ViewBuilder
private func questionBody(_ lexeme: Lexeme) -> some View {
Text(lexeme.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(lexeme)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(formattedSpanish(option))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ lexeme: Lexeme) -> some View {
VStack(spacing: 16) {
answerFeedback(lexeme)
exampleBlock(for: lexeme)
ratingButtons
}
}
private func answerFeedback(_ lexeme: Lexeme) -> some View {
let correct = (selectedOption?.id == lexeme.id)
return VStack(spacing: 6) {
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 36))
.foregroundStyle(correct ? .green : .red)
Text(correct ? "Correct!" : "Not quite")
.font(.headline)
.foregroundStyle(correct ? .green : .red)
Text(formattedSpanish(lexeme))
.font(.title2.weight(.semibold))
.padding(.top, 4)
}
}
@ViewBuilder
private func exampleBlock(for lexeme: Lexeme) -> some View {
if let es = lexeme.exampleES, !es.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text(es).font(.subheadline).italic()
if let en = lexeme.exampleEN, !en.isEmpty {
Text(en).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}
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: LexemeSessionQueue.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))
}
// MARK: - Completion
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(.teal)
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 {
let verb = kind == .reviewLearned ? "reviewed" : "learned"
return "\(learned) noun\(learned == 1 ? "" : "s") \(verb)"
}
switch kind {
case .standard:
return "No nouns are due right now. Study Again to review anyway."
case .reviewLearned:
return "Finish a noun session first, then come back to consolidate."
}
}
// MARK: - Logic
private func loadIfNeeded() {
guard session == nil else { return }
let lexemes: [Lexeme]
switch kind {
case .standard:
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
lexemes = LexemePool.sessionLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
enabledLevels: progress.selectedLexemeLevels,
localContext: localContext,
cloudContext: cloudContext
)
case .reviewLearned:
lexemes = LexemePool.reviewLearnedLexemes(
partOfSpeech: "noun",
drillMode: Self.drillMode,
localContext: localContext,
cloudContext: cloudContext
)
}
distractorPool = lexemes
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
prepareOptions()
}
private func studyAgain() {
session?.restart()
selectedOption = nil
prepareOptions()
}
private func prepareOptions() {
guard let lexeme = currentLexeme else { options = []; return }
let candidates = distractorPool.filter { $0.id != lexeme.id }
let distractors = Array(candidates.shuffled().prefix(3))
options = ([lexeme] + distractors).shuffled()
}
private func answer(_ rating: LexemeSessionQueue.Rating) {
guard let lexeme = currentLexeme else { return }
let graduation = session?.answer(rating)
// Review Learned is a cram pass graduation drives the in-session
// queue only; the long-term schedule is left untouched.
if let graduation, kind == .standard {
LexemeReviewStore(context: cloudContext).rate(
lexemeId: lexeme.id,
partOfSpeech: "noun",
drillMode: Self.drillMode,
quality: graduation
)
}
selectedOption = nil
prepareOptions()
}
private func formattedSpanish(_ lexeme: Lexeme) -> String {
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
let article: String
switch g {
case "f": article = "la"
case "m/f": article = "el/la"
default: article = "el"
}
return "\(article) \(lexeme.baseForm)"
}
}