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>
225 lines
8.8 KiB
Swift
225 lines
8.8 KiB
Swift
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)
|
||
}
|