Add checkpoint exams with cumulative vocabulary review per course
Checkpoint exams appear after each week in the course view, testing all words from week 1 through the current week within the same course. Users can choose 25, 50, or 100 questions with even distribution across weeks. Results are tracked separately from weekly tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; };
|
||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
|
||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
||||
@@ -139,6 +140,7 @@
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
|
||||
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
|
||||
@@ -396,6 +398,7 @@
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
|
||||
);
|
||||
path = Course;
|
||||
sourceTree = "<group>";
|
||||
@@ -584,6 +587,7 @@
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -9,9 +9,15 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
case handwritingEnToEs = "hw_en_to_es"
|
||||
case handwritingEsToEn = "hw_es_to_en"
|
||||
case completeSentenceES = "complete_sentence_es"
|
||||
case checkpoint = "checkpoint"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Quiz types shown in the weekly test picker (excludes checkpoint).
|
||||
static var weeklyQuizTypes: [QuizType] {
|
||||
allCases.filter { $0 != .checkpoint }
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .mcEnToEs: "Multiple Choice: EN → ES"
|
||||
@@ -21,6 +27,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
case .handwritingEnToEs: "Handwriting: EN → ES"
|
||||
case .handwritingEsToEn: "Handwriting: ES → EN"
|
||||
case .completeSentenceES: "Complete the Sentence"
|
||||
case .checkpoint: "Checkpoint Exam"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +37,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
case .typingEnToEs, .typingEsToEn: "keyboard"
|
||||
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
|
||||
case .completeSentenceES: "text.badge.checkmark"
|
||||
case .checkpoint: "checkmark.seal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +50,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
case .handwritingEnToEs: "See English, handwrite the Spanish word"
|
||||
case .handwritingEsToEn: "See Spanish, handwrite the English word"
|
||||
case .completeSentenceES: "Read a Spanish sentence and pick the missing word"
|
||||
case .checkpoint: "Cumulative review of all weeks so far"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,20 +58,20 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
var promptLanguage: String {
|
||||
switch self {
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: "Spanish"
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: "Spanish"
|
||||
}
|
||||
}
|
||||
|
||||
var answerLanguage: String {
|
||||
switch self {
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish"
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English"
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: "English"
|
||||
}
|
||||
}
|
||||
|
||||
var isMultipleChoice: Bool {
|
||||
switch self {
|
||||
case .mcEnToEs, .mcEsToEn, .completeSentenceES: true
|
||||
case .mcEnToEs, .mcEsToEn, .completeSentenceES, .checkpoint: true
|
||||
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
|
||||
}
|
||||
}
|
||||
@@ -81,14 +90,14 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
func prompt(for card: VocabCard) -> String {
|
||||
switch self {
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: card.front
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: card.front
|
||||
}
|
||||
}
|
||||
|
||||
func answer(for card: VocabCard) -> String {
|
||||
switch self {
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: card.back
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
224
Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Normal file
224
Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct CheckpointExamView: View {
|
||||
let courseName: String
|
||||
let throughWeek: Int
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query private var allDecks: [CourseDeck]
|
||||
|
||||
@State private var cardsByWeek: [Int: [VocabCard]] = [:]
|
||||
@State private var checkpointResults: [TestResult] = []
|
||||
@State private var selectedCount = 25
|
||||
|
||||
private let questionCounts = [25, 50, 100]
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var totalAvailable: Int {
|
||||
cardsByWeek.values.reduce(0) { $0 + $1.count }
|
||||
}
|
||||
|
||||
/// Sample evenly across weeks, then fill remainder round-robin.
|
||||
private var sampledCards: [VocabCard] {
|
||||
let weekNumbers = cardsByWeek.keys.sorted()
|
||||
guard !weekNumbers.isEmpty else { return [] }
|
||||
|
||||
let target = min(selectedCount, totalAvailable)
|
||||
let perWeek = target / weekNumbers.count
|
||||
var remainder = target - (perWeek * weekNumbers.count)
|
||||
|
||||
var result: [VocabCard] = []
|
||||
for week in weekNumbers {
|
||||
guard let pool = cardsByWeek[week] else { continue }
|
||||
let shuffled = pool.shuffled()
|
||||
var take = min(perWeek, shuffled.count)
|
||||
// Distribute remainder one extra card per week until exhausted
|
||||
if remainder > 0 && take < shuffled.count {
|
||||
take += 1
|
||||
remainder -= 1
|
||||
}
|
||||
result.append(contentsOf: shuffled.prefix(take))
|
||||
}
|
||||
|
||||
// If some weeks had fewer cards than perWeek, fill from weeks with surplus
|
||||
if result.count < target {
|
||||
let used = Set(result.map { ObjectIdentifier($0) })
|
||||
var extras: [VocabCard] = []
|
||||
for week in weekNumbers {
|
||||
guard let pool = cardsByWeek[week] else { continue }
|
||||
extras.append(contentsOf: pool.filter { !used.contains(ObjectIdentifier($0)) })
|
||||
}
|
||||
extras.shuffle()
|
||||
result.append(contentsOf: extras.prefix(target - result.count))
|
||||
}
|
||||
|
||||
return result.shuffled()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.seal")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.blue)
|
||||
Text("Checkpoint Exam")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
Text("Weeks 1–\(throughWeek)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
// Question count picker
|
||||
VStack(spacing: 8) {
|
||||
Text("Questions")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(questionCounts, id: \.self) { count in
|
||||
let available = count <= totalAvailable
|
||||
Button {
|
||||
withAnimation { selectedCount = count }
|
||||
} label: {
|
||||
Text("\(count)")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(selectedCount == count ? .blue : .secondary)
|
||||
.disabled(!available)
|
||||
.opacity(available ? 1 : 0.4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Label("Multiple choice", systemImage: "list.bullet")
|
||||
Label("Cumulative vocabulary", systemImage: "books.vertical")
|
||||
Label("\(totalAvailable) words available", systemImage: "character.book.closed")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if cardsByWeek.isEmpty {
|
||||
ProgressView("Loading vocabulary...")
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
NavigationLink {
|
||||
CourseQuizView(
|
||||
cards: sampledCards,
|
||||
quizType: .checkpoint,
|
||||
courseName: courseName,
|
||||
weekNumber: throughWeek,
|
||||
isFocusMode: false
|
||||
)
|
||||
} label: {
|
||||
Text("Begin Exam")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Score History
|
||||
if !checkpointResults.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Score History")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(checkpointResults.prefix(10).enumerated()), id: \.offset) { _, result in
|
||||
HStack {
|
||||
Text(result.dateTaken.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(result.scorePercent)%")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(scoreColor(result.scorePercent))
|
||||
Text("\(result.correctCount)/\(result.totalQuestions)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
Divider().padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle("Checkpoint")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
loadCumulativeCards()
|
||||
loadResults()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCumulativeCards() {
|
||||
let course = courseName
|
||||
let maxWeek = throughWeek
|
||||
let weekDecks = allDecks.filter {
|
||||
$0.courseName == course && $0.weekNumber <= maxWeek && !$0.isReversed
|
||||
}
|
||||
var grouped: [Int: [VocabCard]] = [:]
|
||||
for deck in weekDecks {
|
||||
let deckId = deck.id
|
||||
let descriptor = FetchDescriptor<VocabCard>(
|
||||
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
|
||||
)
|
||||
if let fetched = try? modelContext.fetch(descriptor) {
|
||||
grouped[deck.weekNumber, default: []].append(contentsOf: fetched)
|
||||
}
|
||||
}
|
||||
cardsByWeek = grouped
|
||||
}
|
||||
|
||||
private func loadResults() {
|
||||
let course = courseName
|
||||
let week = throughWeek
|
||||
let checkpointType = QuizType.checkpoint.rawValue
|
||||
let descriptor = FetchDescriptor<TestResult>(
|
||||
predicate: #Predicate<TestResult> {
|
||||
$0.courseName == course && $0.weekNumber == week && $0.quizType == checkpointType
|
||||
},
|
||||
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
|
||||
)
|
||||
checkpointResults = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
private func scoreColor(_ percent: Int) -> Color {
|
||||
if percent >= 90 { return .green }
|
||||
if percent >= 70 { return .orange }
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
CheckpointExamView(courseName: "LanGo Spanish | Beginner I", throughWeek: 3)
|
||||
}
|
||||
.modelContainer(for: [TestResult.self, CourseDeck.self, VocabCard.self], inMemory: true)
|
||||
}
|
||||
@@ -104,7 +104,7 @@ struct CourseQuizView: View {
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle(isFocusMode ? "Focus Area" : "Week \(weekNumber) Test")
|
||||
.navigationTitle(isFocusMode ? "Focus Area" : quizType == .checkpoint ? "Checkpoint" : "Week \(weekNumber) Test")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -33,11 +33,20 @@ struct CourseView: View {
|
||||
private func bestScore(for week: Int) -> Int? {
|
||||
let results = testResults.filter {
|
||||
$0.courseName == activeCourse && $0.weekNumber == week
|
||||
&& $0.quizType != QuizType.checkpoint.rawValue
|
||||
}
|
||||
guard !results.isEmpty else { return nil }
|
||||
return results.map(\.scorePercent).max()
|
||||
}
|
||||
|
||||
private func bestCheckpointScore(for week: Int) -> Int? {
|
||||
let results = testResults.filter {
|
||||
$0.courseName == activeCourse && $0.weekNumber == week
|
||||
&& $0.quizType == QuizType.checkpoint.rawValue
|
||||
}
|
||||
return results.map(\.scorePercent).max()
|
||||
}
|
||||
|
||||
private func shortName(_ full: String) -> String {
|
||||
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
|
||||
.replacingOccurrences(of: "LanGo Spanish ", with: "")
|
||||
@@ -103,6 +112,32 @@ struct CourseView: View {
|
||||
DeckRowView(deck: deck)
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint exam
|
||||
NavigationLink(value: CheckpointDestination(courseName: activeCourse, throughWeek: week)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.seal")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Checkpoint Exam")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Cumulative review: Weeks 1–\(week)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let best = bestCheckpointScore(for: week) {
|
||||
Text("\(best)%")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(best >= 90 ? .green : best >= 70 ? .orange : .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Week \(week)")
|
||||
}
|
||||
@@ -117,6 +152,9 @@ struct CourseView: View {
|
||||
.navigationDestination(for: WeekTestDestination.self) { dest in
|
||||
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
.navigationDestination(for: CheckpointDestination.self) { dest in
|
||||
CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +170,11 @@ struct WeekTestDestination: Hashable {
|
||||
let weekNumber: Int
|
||||
}
|
||||
|
||||
struct CheckpointDestination: Hashable {
|
||||
let courseName: String
|
||||
let throughWeek: Int
|
||||
}
|
||||
|
||||
// MARK: - Deck Row
|
||||
|
||||
private struct DeckRowView: View {
|
||||
|
||||
@@ -56,7 +56,7 @@ struct WeekTestView: View {
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
ForEach(QuizType.allCases) { type in
|
||||
ForEach(QuizType.weeklyQuizTypes, id: \.self) { type in
|
||||
NavigationLink {
|
||||
CourseQuizView(
|
||||
cards: weekCards,
|
||||
|
||||
Reference in New Issue
Block a user