Files
Spanish/Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Trey t d372a5c77f 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>
2026-04-12 12:45:25 -05:00

225 lines
8.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}