diff --git a/CanvasPlusPlayground.xcodeproj/project.pbxproj b/CanvasPlusPlayground.xcodeproj/project.pbxproj index 4c2e2cf..4630a23 100644 --- a/CanvasPlusPlayground.xcodeproj/project.pbxproj +++ b/CanvasPlusPlayground.xcodeproj/project.pbxproj @@ -85,6 +85,8 @@ B7F9503B2D127AD0004BB470 /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9503A2D127AD0004BB470 /* Profile.swift */; }; B7F9503D2D12ADAE004BB470 /* Submission.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9503C2D12ADAE004BB470 /* Submission.swift */; }; B7F9503F2D133435004BB470 /* AssignmentGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9503E2D133435004BB470 /* AssignmentGroup.swift */; }; + B7F950412D149290004BB470 /* CourseGradeCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F950402D14928C004BB470 /* CourseGradeCalculator.swift */; }; + B7F950432D149C5E004BB470 /* GradeCalculatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F950422D149C5E004BB470 /* GradeCalculatorView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -166,6 +168,8 @@ B7F9503A2D127AD0004BB470 /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = ""; }; B7F9503C2D12ADAE004BB470 /* Submission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Submission.swift; sourceTree = ""; }; B7F9503E2D133435004BB470 /* AssignmentGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignmentGroup.swift; sourceTree = ""; }; + B7F950402D14928C004BB470 /* CourseGradeCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseGradeCalculator.swift; sourceTree = ""; }; + B7F950422D149C5E004BB470 /* GradeCalculatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradeCalculatorView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -386,6 +390,7 @@ A324BA5F2D0798A1005F53FA /* Grades */ = { isa = PBXGroup; children = ( + B7F950442D149C6E004BB470 /* GradeCalculator */, 7F8535732C98DE3C0023E384 /* CourseGradeView.swift */, A31A81E62D0CCB69003C37EB /* GradesViewModel.swift */, ); @@ -480,6 +485,15 @@ path = Profile; sourceTree = ""; }; + B7F950442D149C6E004BB470 /* GradeCalculator */ = { + isa = PBXGroup; + children = ( + B7F950422D149C5E004BB470 /* GradeCalculatorView.swift */, + B7F950402D14928C004BB470 /* CourseGradeCalculator.swift */, + ); + path = GradeCalculator; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -591,12 +605,14 @@ A3FFD03E2CE0065A006BAB51 /* NetworkError.swift in Sources */, B53D95A22CA0A22A00647EE9 /* PeopleView.swift in Sources */, B7F9503F2D133435004BB470 /* AssignmentGroup.swift in Sources */, + B7F950432D149C5E004BB470 /* GradeCalculatorView.swift in Sources */, B76455042C8DF61B002DF00E /* CourseView.swift in Sources */, A3049B682D0F3ECA002F3166 /* QuizzesViewModel.swift in Sources */, 9B92A4252C93856100C21CFC /* CourseAnnouncementManager.swift in Sources */, B76455052C8DF61B002DF00E /* SetupView.swift in Sources */, B7AD54F32CD41D7900FB09BB /* LLMEvaluator.swift in Sources */, B7AD54F42CD41D7900FB09BB /* Models.swift in Sources */, + B7F950412D149290004BB470 /* CourseGradeCalculator.swift in Sources */, B7AD54F52CD41D7900FB09BB /* Data.swift in Sources */, B7F9503D2D12ADAE004BB470 /* Submission.swift in Sources */, B7AD54F62CD41D7900FB09BB /* DeviceStat.swift in Sources */, diff --git a/CanvasPlusPlayground/Common/Utilities/String+Numbers.swift b/CanvasPlusPlayground/Common/Utilities/String+Numbers.swift index 8a4fe6a..32ccacc 100644 --- a/CanvasPlusPlayground/Common/Utilities/String+Numbers.swift +++ b/CanvasPlusPlayground/Common/Utilities/String+Numbers.swift @@ -28,4 +28,8 @@ extension Double { var toInt: Int { Int(self) } + + func rounded(toPlaces places: Int) -> String { + String(format: "%.\(places)f", self) + } } diff --git a/CanvasPlusPlayground/Features/Assignments/CourseAssignmentView.swift b/CanvasPlusPlayground/Features/Assignments/CourseAssignmentView.swift index d21649d..bb2b7b8 100644 --- a/CanvasPlusPlayground/Features/Assignments/CourseAssignmentView.swift +++ b/CanvasPlusPlayground/Features/Assignments/CourseAssignmentView.swift @@ -13,6 +13,7 @@ struct CourseAssignmentsView: View { @State private var assignmentManager: CourseAssignmentManager @State private var isLoadingAssignments = true + @State private var showGradeCalculator: Bool = false init(course: Course, showGrades: Bool = false) { self.course = course @@ -43,6 +44,17 @@ struct CourseAssignmentsView: View { } .statusToolbarItem("Assignments", isVisible: isLoadingAssignments) .navigationTitle(course.displayName) + .sheet(isPresented: $showGradeCalculator) { + NavigationStack { + GradeCalculatorView( + assignmentGroups: assignmentManager.assignmentGroups + ) + } + } + .toolbar { + Button("Calculate Grades") { showGradeCalculator = true } + .disabled(assignmentManager.assignmentGroups.isEmpty) + } } private func loadAssignments() async { diff --git a/CanvasPlusPlayground/Features/Grades/GradeCalculator/CourseGradeCalculator.swift b/CanvasPlusPlayground/Features/Grades/GradeCalculator/CourseGradeCalculator.swift new file mode 100644 index 0000000..434b444 --- /dev/null +++ b/CanvasPlusPlayground/Features/Grades/GradeCalculator/CourseGradeCalculator.swift @@ -0,0 +1,128 @@ +// +// CourseGradeCalculator.swift +// CanvasPlusPlayground +// +// Created by Rahul on 12/19/24. +// + +import SwiftUI + +@Observable +class CourseGradeCalculatorBridge { + typealias GradeCalculatorAssignmentGroup = CourseGradeCalculator.GradeCalculatorAssignmentGroup + + private(set) var _calculator: CourseGradeCalculator + + var assignmentGroups: [GradeCalculatorAssignmentGroup] { + get { + _calculator.assignmentGroups + } + + set { + _calculator.assignmentGroups = newValue + } + } + + var finalGrade: String { + _calculator.finalGrade + } + + var expandedAssignmentGroups: [GradeCalculatorAssignmentGroup: Bool] = [:] + + init(from assignmentGroups: [AssignmentGroup]) { + _calculator = CourseGradeCalculator(from: assignmentGroups) + expandedAssignmentGroups = Dictionary( + uniqueKeysWithValues: _calculator.assignmentGroups.lazy.map { ($0, true) } + ) + } +} + +struct CourseGradeCalculator { + struct GradeCalculatorAssignmentGroup: Identifiable, Hashable { + let id: Int + let name: String + var assignments: [GradeCalculatorAssignment] + var groupWeight: Double + + var assignmentWeight: Double { + groupWeight / Double(assignments.count) + } + + var displayWeight: String { + "\((groupWeight * 100.0).truncatingTrailingZeros)%" + } + } + + struct GradeCalculatorAssignment: Identifiable, Hashable { + let id: Int + let name: String + var weight: Double? + var pointsPossible: Double? + var score: Double? + } + + var assignmentGroups: [GradeCalculatorAssignmentGroup] + + var finalGrade: String { + var grade = calculateFinalGrade() + if grade.isNaN { + grade = 0 + } + + return Double(grade * 100.0).rounded(toPlaces: 2) + "%" + } + + init(from canvasAssignmentGroup: [AssignmentGroup]) { + assignmentGroups = canvasAssignmentGroup.map { group in + GradeCalculatorAssignmentGroup( + id: group.id ?? UUID().hashValue, + name: group.name ?? "", + assignments: group.assignments?.map { assignment in + GradeCalculatorAssignment( + id: assignment.id, + name: assignment.name ?? "", + weight: nil, + pointsPossible: assignment.pointsPossible ?? 0, + score: assignment.submission?.score + ) + } ?? [], + groupWeight: (Double(group.groupWeight ?? 0) / 100.0) + ) + } + } + + // MARK: - Private + private func calculateFinalGrade() -> Double { + var totalWeight = assignmentGroups.reduce(0.0) { total, group in + total + group.groupWeight + } + + // Groups are unweighted + if totalWeight == 0 { + var totalPossible: Double = 0 + let earned: Double = assignmentGroups.reduce(0) { total, group in + total + group.assignments.reduce(0) { total, assignment in + guard let score = assignment.score, let pointsPossible = assignment.pointsPossible else { + return total + } + + totalPossible += pointsPossible + + return Double(total + score) + } + } + + return earned / totalPossible + } else { + return assignmentGroups.reduce(0) { total, group in + total + group.assignments.reduce(0) { total, assignment in + guard let score = assignment.score, let pointsPossible = assignment.pointsPossible else { + return total + } + + return total + (score / pointsPossible) * (assignment.weight ?? group.assignmentWeight) + } + } / totalWeight + } + } +} diff --git a/CanvasPlusPlayground/Features/Grades/GradeCalculator/GradeCalculatorView.swift b/CanvasPlusPlayground/Features/Grades/GradeCalculator/GradeCalculatorView.swift new file mode 100644 index 0000000..6574297 --- /dev/null +++ b/CanvasPlusPlayground/Features/Grades/GradeCalculator/GradeCalculatorView.swift @@ -0,0 +1,115 @@ +// +// GradeCalculatorView.swift +// CanvasPlusPlayground +// +// Created by Rahul on 12/19/24. +// + +import SwiftUI + +struct GradeCalculatorView: View { + @Environment(\.dismiss) private var dismiss + @State private var gradeCalculator: CourseGradeCalculatorBridge + + @FocusState private var assignmentRowFocus: CourseGradeCalculator.GradeCalculatorAssignment? + + init(assignmentGroups: [AssignmentGroup]) { + _gradeCalculator = .init( + initialValue: CourseGradeCalculatorBridge(from: assignmentGroups) + ) + } + + var body: some View { + List($gradeCalculator.assignmentGroups, id: \.id) { $group in + DisclosureGroup( + isExpanded: isExpanded(for: group) + ) { + ForEach($group.assignments, id: \.id) { $assignment in + assignmentRow(for: $assignment) + } + } label: { + groupHeader(for: group) + } + } + #if os(macOS) + .frame(width: 600, height: 400) + #endif + .navigationTitle("Calculate Grades") + .toolbar { + ToolbarItem(placement: .destructiveAction) { + Text("Total: \(gradeCalculator.finalGrade)") + .contentTransition(.numericText()) + .animation(.default, value: gradeCalculator.finalGrade) + } + + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + #if os(macOS) + Text("Done") + #else + Image(systemName: "xmark") + #endif + } + .keyboardShortcut(assignmentRowFocus == nil ? .defaultAction : .none) + } + + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + assignmentRowFocus = nil + } + .bold() + } + } + } + + private func groupHeader(for group: CourseGradeCalculatorBridge.GradeCalculatorAssignmentGroup) -> some View { + HStack { + Text(group.name) + Spacer() + Text("\(group.displayWeight)") + } + .bold() + .padding(4) + } + + private func assignmentRow(for assignment: Binding) -> some View { + HStack { + Text(assignment.wrappedValue.name) + + Spacer() + + TextField( + "Score", + value: assignment.score, + format: .number + ) + .focused($assignmentRowFocus, equals: assignment.wrappedValue) + .fixedSize() + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.tint) + #if os(iOS) + .keyboardType(.numberPad) + #endif + + Text( + " / " + + "\(assignment.wrappedValue.pointsPossible?.truncatingTrailingZeros ?? "-")" + ) + } + .padding(.vertical, 4) + } + + private func isExpanded( + for group: CourseGradeCalculatorBridge.GradeCalculatorAssignmentGroup + ) -> Binding { + .init { + gradeCalculator.expandedAssignmentGroups[group, default: true] + } set: { newValue in + gradeCalculator.expandedAssignmentGroups[group] = newValue + } + } +}