Files
Spanish/Conjuga/Conjuga/Views/Course/CourseView.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

221 lines
8.5 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 CourseView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
@AppStorage("selectedCourse") private var selectedCourse: String?
@State private var testResults: [TestResult] = []
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var courseNames: [String] {
let names = Set(decks.map(\.courseName))
return names.sorted()
}
private var activeCourse: String {
selectedCourse ?? courseNames.first ?? ""
}
private var filteredDecks: [CourseDeck] {
decks.filter { $0.courseName == activeCourse }
}
private var weekGroups: [(week: Int, decks: [CourseDeck])] {
let grouped = Dictionary(grouping: filteredDecks, by: \.weekNumber)
return grouped.keys.sorted().map { week in
(week, grouped[week]!.sorted { $0.title < $1.title })
}
}
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: "")
}
var body: some View {
NavigationStack {
List {
if decks.isEmpty {
ContentUnavailableView(
"No Course Data",
systemImage: "tray",
description: Text("Course data is loading...")
)
} else {
// Course picker
if courseNames.count > 1 {
Section {
Picker("Course", selection: Binding(
get: { activeCourse },
set: { selectedCourse = $0 }
)) {
ForEach(courseNames, id: \.self) { name in
Text(shortName(name)).tag(name)
}
}
.pickerStyle(.menu)
}
}
// Week sections
ForEach(weekGroups, id: \.week) { week, weekDecks in
Section {
// Test button
NavigationLink(value: WeekTestDestination(courseName: activeCourse, weekNumber: week)) {
HStack(spacing: 12) {
Image(systemName: "pencil.and.list.clipboard")
.font(.title3)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Take Test")
.font(.subheadline.weight(.semibold))
Text("Multiple quiz types available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let best = bestScore(for: week) {
Text("\(best)%")
.font(.subheadline.weight(.bold))
.foregroundStyle(best >= 90 ? .green : best >= 70 ? .orange : .red)
}
}
}
// Deck rows
ForEach(weekDecks) { deck in
NavigationLink(value: deck) {
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)")
}
}
}
}
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
.onAppear(perform: loadTestResults)
.navigationDestination(for: CourseDeck.self) { deck in
DeckStudyView(deck: deck)
}
.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)
}
}
}
private func loadTestResults() {
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
}
}
// MARK: - Navigation
struct WeekTestDestination: Hashable {
let courseName: String
let weekNumber: Int
}
struct CheckpointDestination: Hashable {
let courseName: String
let throughWeek: Int
}
// MARK: - Deck Row
private struct DeckRowView: View {
let deck: CourseDeck
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(deck.title)
.font(.headline)
HStack(spacing: 8) {
Label("\(deck.cardCount) cards", systemImage: "rectangle.on.rectangle")
.font(.caption)
.foregroundStyle(.secondary)
if deck.isReversed {
Text("EN → ES")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.blue.opacity(0.12), in: Capsule())
.foregroundStyle(.blue)
} else {
Text("ES → EN")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.orange.opacity(0.12), in: Capsule())
.foregroundStyle(.orange)
}
}
}
Spacer()
}
}
}
#Preview {
CourseView()
.modelContainer(for: [CourseDeck.self, TestResult.self], inMemory: true)
}