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

255 lines
10 KiB
Swift

import SwiftUI
import SharedModels
import SwiftData
struct WeekTestView: View {
let courseName: String
let weekNumber: Int
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query private var allDecks: [CourseDeck]
@State private var loadedWeekCards: [VocabCard] = []
@State private var weekResults: [TestResult] = []
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var weekCards: [VocabCard] {
loadedWeekCards
}
private var missedItems: [MissedCourseItem] {
var missed: [String: MissedCourseItem] = [:]
for result in weekResults {
for item in result.missedItems {
missed[item.front] = item
}
}
if let latest = weekResults.first {
let latestMissed = Set(latest.missedItems.map(\.front))
missed = missed.filter { latestMissed.contains($0.key) }
}
return missed.values.sorted { $0.front < $1.front }
}
private var focusCards: [VocabCard] {
let missedFronts = Set(missedItems.map(\.front))
return weekCards.filter { missedFronts.contains($0.front) }
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header
VStack(spacing: 4) {
Text("Week \(weekNumber)")
.font(.largeTitle.weight(.bold))
Text("Test Your Knowledge")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
// Quiz type selection
VStack(alignment: .leading, spacing: 12) {
Text("Choose Quiz Type")
.font(.headline)
.padding(.horizontal)
ForEach(QuizType.weeklyQuizTypes, id: \.self) { type in
NavigationLink {
CourseQuizView(
cards: weekCards,
quizType: type,
courseName: courseName,
weekNumber: weekNumber,
isFocusMode: false
)
} label: {
HStack(spacing: 14) {
Image(systemName: type.icon)
.font(.title3)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(type.label)
.font(.subheadline.weight(.semibold))
Text(type.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
}
}
// Focus Area
if !missedItems.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("Focus Area", systemImage: "target")
.font(.headline)
.foregroundStyle(.red)
Spacer()
Text("\(missedItems.count) items")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
// Missed items preview
VStack(spacing: 0) {
ForEach(Array(missedItems.prefix(5).enumerated()), id: \.offset) { _, item in
HStack {
Text(item.front)
.font(.subheadline.weight(.medium))
Spacer()
Text(item.back)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
Divider().padding(.leading, 14)
}
if missedItems.count > 5 {
Text("+ \(missedItems.count - 5) more")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 8)
}
}
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
// Study focus button
NavigationLink {
CourseQuizView(
cards: focusCards,
quizType: .mcEsToEn,
courseName: courseName,
weekNumber: weekNumber,
isFocusMode: true
)
} label: {
Label("Study Focus Area", systemImage: "brain.head.profile")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.padding(.horizontal)
}
}
// Score History
if !weekResults.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("Score History")
.font(.headline)
.padding(.horizontal)
VStack(spacing: 0) {
ForEach(Array(weekResults.prefix(10).enumerated()), id: \.offset) { _, result in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(QuizType(rawValue: result.quizType)?.label ?? result.quizType)
.font(.subheadline.weight(.medium))
Text(result.dateTaken.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.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("Week \(weekNumber) Test")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
loadResults()
loadCards()
}
}
private func loadCards() {
let course = courseName
let week = weekNumber
let weekDecks = allDecks.filter {
$0.courseName == course && $0.weekNumber == week && !$0.isReversed
}
let deckIds = weekDecks.map(\.id)
var cards: [VocabCard] = []
for deckId in deckIds {
let descriptor = FetchDescriptor<VocabCard>(
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
)
if let fetched = try? modelContext.fetch(descriptor) {
cards.append(contentsOf: fetched)
}
}
loadedWeekCards = cards
}
private func loadResults() {
let course = courseName
let week = weekNumber
let descriptor = FetchDescriptor<TestResult>(
predicate: #Predicate<TestResult> {
$0.courseName == course && $0.weekNumber == week
},
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
)
weekResults = (try? cloudModelContext.fetch(descriptor)) ?? []
}
private func scoreColor(_ percent: Int) -> Color {
if percent >= 90 { return .green }
if percent >= 70 { return .orange }
return .red
}
}
#Preview {
NavigationStack {
WeekTestView(courseName: "LanGo Spanish | Beginner I", weekNumber: 1)
}
.modelContainer(for: [TestResult.self, CourseDeck.self, VocabCard.self], inMemory: true)
}