aab64116b3
- 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>
258 lines
9.1 KiB
Swift
258 lines
9.1 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
|
|
/// English-first adjective multiple choice — non-verb analog of
|
|
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
|
|
/// adjective pool; 4 options (1 correct + 3 random distractors from the
|
|
/// session). Options are bare base forms — agreement isn't drilled here.
|
|
struct AdjectiveMultipleChoicePracticeView: 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" : "Adjective Multiple Choice")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear(perform: loadIfNeeded)
|
|
.animation(.smooth, value: selectedOption?.id)
|
|
.animation(.smooth, value: currentLexeme?.id)
|
|
}
|
|
|
|
private var progressBar: some View {
|
|
VStack(spacing: 6) {
|
|
ProgressView(value: session?.progress ?? 0).tint(.pink)
|
|
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"
|
|
}
|
|
|
|
@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(option.baseForm)
|
|
.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(lexeme.baseForm)
|
|
.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))
|
|
}
|
|
|
|
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(.pink)
|
|
|
|
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) adjective\(learned == 1 ? "" : "s") \(verb)"
|
|
}
|
|
switch kind {
|
|
case .standard:
|
|
return "No adjectives are due right now. Study Again to review anyway."
|
|
case .reviewLearned:
|
|
return "Finish an adjective session first, then come back to consolidate."
|
|
}
|
|
}
|
|
|
|
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: "adjective",
|
|
drillMode: Self.drillMode,
|
|
enabledLevels: progress.selectedLexemeLevels,
|
|
localContext: localContext,
|
|
cloudContext: cloudContext
|
|
)
|
|
case .reviewLearned:
|
|
lexemes = LexemePool.reviewLearnedLexemes(
|
|
partOfSpeech: "adjective",
|
|
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: "adjective",
|
|
drillMode: Self.drillMode,
|
|
quality: graduation
|
|
)
|
|
}
|
|
selectedOption = nil
|
|
prepareOptions()
|
|
}
|
|
}
|