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>
221 lines
8.5 KiB
Swift
221 lines
8.5 KiB
Swift
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)
|
||
}
|