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>
255 lines
10 KiB
Swift
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)
|
|
}
|