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( predicate: #Predicate { $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( predicate: #Predicate { $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) }