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>
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||||
|
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; };
|
||||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
||||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
|
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
|
||||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
||||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
|
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
|
||||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
|
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
|
||||||
|
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
|
||||||
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
|
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
|
||||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
||||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
|
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
|
||||||
@@ -396,6 +398,7 @@
|
|||||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||||
|
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
|
||||||
);
|
);
|
||||||
path = Course;
|
path = Course;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -584,6 +587,7 @@
|
|||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||||
|
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
case handwritingEnToEs = "hw_en_to_es"
|
case handwritingEnToEs = "hw_en_to_es"
|
||||||
case handwritingEsToEn = "hw_es_to_en"
|
case handwritingEsToEn = "hw_es_to_en"
|
||||||
case completeSentenceES = "complete_sentence_es"
|
case completeSentenceES = "complete_sentence_es"
|
||||||
|
case checkpoint = "checkpoint"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Quiz types shown in the weekly test picker (excludes checkpoint).
|
||||||
|
static var weeklyQuizTypes: [QuizType] {
|
||||||
|
allCases.filter { $0 != .checkpoint }
|
||||||
|
}
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs: "Multiple Choice: EN → ES"
|
case .mcEnToEs: "Multiple Choice: EN → ES"
|
||||||
@@ -21,6 +27,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
case .handwritingEnToEs: "Handwriting: EN → ES"
|
case .handwritingEnToEs: "Handwriting: EN → ES"
|
||||||
case .handwritingEsToEn: "Handwriting: ES → EN"
|
case .handwritingEsToEn: "Handwriting: ES → EN"
|
||||||
case .completeSentenceES: "Complete the Sentence"
|
case .completeSentenceES: "Complete the Sentence"
|
||||||
|
case .checkpoint: "Checkpoint Exam"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +37,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
case .typingEnToEs, .typingEsToEn: "keyboard"
|
case .typingEnToEs, .typingEsToEn: "keyboard"
|
||||||
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
|
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
|
||||||
case .completeSentenceES: "text.badge.checkmark"
|
case .completeSentenceES: "text.badge.checkmark"
|
||||||
|
case .checkpoint: "checkmark.seal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +50,7 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
case .handwritingEnToEs: "See English, handwrite the Spanish word"
|
case .handwritingEnToEs: "See English, handwrite the Spanish word"
|
||||||
case .handwritingEsToEn: "See Spanish, handwrite the English word"
|
case .handwritingEsToEn: "See Spanish, handwrite the English word"
|
||||||
case .completeSentenceES: "Read a Spanish sentence and pick the missing word"
|
case .completeSentenceES: "Read a Spanish sentence and pick the missing word"
|
||||||
|
case .checkpoint: "Cumulative review of all weeks so far"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,20 +58,20 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
var promptLanguage: String {
|
var promptLanguage: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
|
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
|
||||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: "Spanish"
|
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: "Spanish"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var answerLanguage: String {
|
var answerLanguage: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish"
|
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish"
|
||||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English"
|
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: "English"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isMultipleChoice: Bool {
|
var isMultipleChoice: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .mcEsToEn, .completeSentenceES: true
|
case .mcEnToEs, .mcEsToEn, .completeSentenceES, .checkpoint: true
|
||||||
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
|
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,14 +90,14 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
|||||||
func prompt(for card: VocabCard) -> String {
|
func prompt(for card: VocabCard) -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
|
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
|
||||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES: card.front
|
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: card.front
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func answer(for card: VocabCard) -> String {
|
func answer(for card: VocabCard) -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front
|
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front
|
||||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back
|
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: card.back
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
224
Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Normal file
224
Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ struct CourseQuizView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.adaptiveContainer()
|
.adaptiveContainer()
|
||||||
}
|
}
|
||||||
.navigationTitle(isFocusMode ? "Focus Area" : "Week \(weekNumber) Test")
|
.navigationTitle(isFocusMode ? "Focus Area" : quizType == .checkpoint ? "Checkpoint" : "Week \(weekNumber) Test")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
|||||||
@@ -33,11 +33,20 @@ struct CourseView: View {
|
|||||||
private func bestScore(for week: Int) -> Int? {
|
private func bestScore(for week: Int) -> Int? {
|
||||||
let results = testResults.filter {
|
let results = testResults.filter {
|
||||||
$0.courseName == activeCourse && $0.weekNumber == week
|
$0.courseName == activeCourse && $0.weekNumber == week
|
||||||
|
&& $0.quizType != QuizType.checkpoint.rawValue
|
||||||
}
|
}
|
||||||
guard !results.isEmpty else { return nil }
|
guard !results.isEmpty else { return nil }
|
||||||
return results.map(\.scorePercent).max()
|
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 {
|
private func shortName(_ full: String) -> String {
|
||||||
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
|
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
|
||||||
.replacingOccurrences(of: "LanGo Spanish ", with: "")
|
.replacingOccurrences(of: "LanGo Spanish ", with: "")
|
||||||
@@ -103,6 +112,32 @@ struct CourseView: View {
|
|||||||
DeckRowView(deck: 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: {
|
} header: {
|
||||||
Text("Week \(week)")
|
Text("Week \(week)")
|
||||||
}
|
}
|
||||||
@@ -117,6 +152,9 @@ struct CourseView: View {
|
|||||||
.navigationDestination(for: WeekTestDestination.self) { dest in
|
.navigationDestination(for: WeekTestDestination.self) { dest in
|
||||||
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||||
}
|
}
|
||||||
|
.navigationDestination(for: CheckpointDestination.self) { dest in
|
||||||
|
CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +170,11 @@ struct WeekTestDestination: Hashable {
|
|||||||
let weekNumber: Int
|
let weekNumber: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CheckpointDestination: Hashable {
|
||||||
|
let courseName: String
|
||||||
|
let throughWeek: Int
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Deck Row
|
// MARK: - Deck Row
|
||||||
|
|
||||||
private struct DeckRowView: View {
|
private struct DeckRowView: View {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ struct WeekTestView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
ForEach(QuizType.allCases) { type in
|
ForEach(QuizType.weeklyQuizTypes, id: \.self) { type in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
CourseQuizView(
|
CourseQuizView(
|
||||||
cards: weekCards,
|
cards: weekCards,
|
||||||
|
|||||||
Reference in New Issue
Block a user