diff --git a/CanvasPlusPlayground.xcodeproj/project.pbxproj b/CanvasPlusPlayground.xcodeproj/project.pbxproj index 385aaa4..8071d25 100644 --- a/CanvasPlusPlayground.xcodeproj/project.pbxproj +++ b/CanvasPlusPlayground.xcodeproj/project.pbxproj @@ -91,14 +91,19 @@ B7AD54F52CD41D7900FB09BB /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AD54EF2CD41D7900FB09BB /* Data.swift */; }; B7AD54F62CD41D7900FB09BB /* DeviceStat.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AD54F02CD41D7900FB09BB /* DeviceStat.swift */; }; B7AD550C2CD4257B00FB09BB /* IntelligenceOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AD550B2CD4257400FB09BB /* IntelligenceOnboardingView.swift */; }; + B7D1365E2D15B63E0044A9E4 /* CourseGradeCalculatorBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D1365D2D15B63E0044A9E4 /* CourseGradeCalculatorBridge.swift */; }; B7D95D772D07C3D3002AD955 /* ICSParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D95D762D07C3D3002AD955 /* ICSParser.swift */; }; B7D95DB72D0A8D78002AD955 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D95DB62D0A8D75002AD955 /* SettingsView.swift */; }; + B7E59A0B2D1CFF8C001836FE /* GetAssignmentGroupsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7E59A0A2D1CFF8C001836FE /* GetAssignmentGroupsRequest.swift */; }; B7F950322D118EAF004BB470 /* String+StripHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F950312D118EAA004BB470 /* String+StripHTML.swift */; }; B7F950342D11A00F004BB470 /* StatusToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F950332D11A00F004BB470 /* StatusToolbarItem.swift */; }; B7F950372D127869004BB470 /* ProfileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F950362D127861004BB470 /* ProfileManager.swift */; }; B7F950392D1279A1004BB470 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F950382D1279A1004BB470 /* User.swift */; }; 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 */ @@ -186,14 +191,19 @@ B7AD54F12CD41D7900FB09BB /* LLMEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMEvaluator.swift; sourceTree = ""; }; B7AD54F22CD41D7900FB09BB /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; B7AD550B2CD4257400FB09BB /* IntelligenceOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntelligenceOnboardingView.swift; sourceTree = ""; }; + B7D1365D2D15B63E0044A9E4 /* CourseGradeCalculatorBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseGradeCalculatorBridge.swift; sourceTree = ""; }; B7D95D762D07C3D3002AD955 /* ICSParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICSParser.swift; sourceTree = ""; }; B7D95DB62D0A8D75002AD955 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B7E59A0A2D1CFF8C001836FE /* GetAssignmentGroupsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetAssignmentGroupsRequest.swift; sourceTree = ""; }; B7F950312D118EAA004BB470 /* String+StripHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+StripHTML.swift"; sourceTree = ""; }; B7F950332D11A00F004BB470 /* StatusToolbarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusToolbarItem.swift; sourceTree = ""; }; B7F950362D127861004BB470 /* ProfileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManager.swift; sourceTree = ""; }; B7F950382D1279A1004BB470 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 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 */ @@ -250,6 +260,7 @@ A3049B7E2D160C03002F3166 /* GetTabsRequest.swift */, A3049B802D160F6B002F3166 /* GetAnnouncementsRequest.swift */, A3049B822D161432002F3166 /* GetAssignmentsRequest.swift */, + B7E59A0A2D1CFF8C001836FE /* GetAssignmentGroupsRequest.swift */, A3049B842D161455002F3166 /* GetEnrollmentsRequest.swift */, A3049B862D16146D002F3166 /* GetQuizzesRequest.swift */, A3D1614E2D1784E3004055FB /* GetUserRequest.swift */, @@ -437,6 +448,7 @@ A324BA5F2D0798A1005F53FA /* Grades */ = { isa = PBXGroup; children = ( + B7F950442D149C6E004BB470 /* GradeCalculator */, 7F8535732C98DE3C0023E384 /* CourseGradeView.swift */, A31A81E62D0CCB69003C37EB /* GradesViewModel.swift */, ); @@ -446,6 +458,7 @@ A324BA602D079927005F53FA /* Plain */ = { isa = PBXGroup; children = ( + B7F9503E2D133435004BB470 /* AssignmentGroup.swift */, B7F9503A2D127AD0004BB470 /* Profile.swift */, B7F950382D1279A1004BB470 /* User.swift */, 192EC0492C963B9000AF8528 /* Assignment.swift */, @@ -530,6 +543,16 @@ path = Profile; sourceTree = ""; }; + B7F950442D149C6E004BB470 /* GradeCalculator */ = { + isa = PBXGroup; + children = ( + B7F950402D14928C004BB470 /* CourseGradeCalculator.swift */, + B7D1365D2D15B63E0044A9E4 /* CourseGradeCalculatorBridge.swift */, + B7F950422D149C5E004BB470 /* GradeCalculatorView.swift */, + ); + path = GradeCalculator; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -630,6 +653,7 @@ 192EC0482C963ACB00AF8528 /* CourseAssignmentManager.swift in Sources */, B7D95D772D07C3D3002AD955 /* ICSParser.swift in Sources */, B7F9503B2D127AD0004BB470 /* Profile.swift in Sources */, + B7E59A0B2D1CFF8C001836FE /* GetAssignmentGroupsRequest.swift in Sources */, B76455012C8DF61B002DF00E /* StorageKeys.swift in Sources */, A3FFD03A2CDEC2AC006BAB51 /* LookupCondition.swift in Sources */, B7D95DB72D0A8D78002AD955 /* SettingsView.swift in Sources */, @@ -644,6 +668,8 @@ A3FFD03E2CE0065A006BAB51 /* NetworkError.swift in Sources */, B53D95A22CA0A22A00647EE9 /* PeopleView.swift in Sources */, A3049B752D15DC1C002F3166 /* GetCoursesRequest.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 */, @@ -651,6 +677,7 @@ A3049B772D15DC36002F3166 /* GetCourseRequest.swift in Sources */, B7AD54F32CD41D7900FB09BB /* LLMEvaluator.swift in Sources */, B7AD54F42CD41D7900FB09BB /* Models.swift in Sources */, + B7F950412D149290004BB470 /* CourseGradeCalculator.swift in Sources */, B7AD54F52CD41D7900FB09BB /* Data.swift in Sources */, A3049B852D161455002F3166 /* GetEnrollmentsRequest.swift in Sources */, A3D161472D169C0A004055FB /* APIRequest+Network.swift in Sources */, @@ -690,6 +717,7 @@ 9B9C2E062C93F94C00E4B16B /* CourseAnnouncementDetailView.swift in Sources */, A3D161512D178615004055FB /* GetUserProfileRequest.swift in Sources */, A3D161492D169C23004055FB /* APIRequest+Storage.swift in Sources */, + B7D1365E2D15B63E0044A9E4 /* CourseGradeCalculatorBridge.swift in Sources */, 9B6663E32C9853BC0060990E /* HTMLTextView.swift in Sources */, B7AD550C2CD4257B00FB09BB /* IntelligenceOnboardingView.swift in Sources */, A3049B792D15E162002F3166 /* GetCourseRootFolder.swift in Sources */, diff --git a/CanvasPlusPlayground/Common/Network/API Requests/GetAssignmentGroupsRequest.swift b/CanvasPlusPlayground/Common/Network/API Requests/GetAssignmentGroupsRequest.swift new file mode 100644 index 0000000..9376f10 --- /dev/null +++ b/CanvasPlusPlayground/Common/Network/API Requests/GetAssignmentGroupsRequest.swift @@ -0,0 +1,74 @@ +// +// GetAssignmentGroupsRequest.swift +// CanvasPlusPlayground +// +// Created by Rahul on 12/26/24. +// + +import Foundation + +struct GetAssignmentGroupsRequest: ArrayAPIRequest { + typealias Subject = AssignmentGroup + + let courseId: String + + // Path for the request + var path: String { "courses/\(courseId)/assignment_groups" } + + // Query parameters + var queryParameters: [QueryParameter] { + [ + ("override_assignment_dates", overrideAssignmentDates), + ("grading_period_id", gradingPeriodId), + ("scope_assignments_to_student", scopeAssignmentsToStudent) + ] + + include.map { ("include[]", $0) } + + assignmentIds.map { ("assignment_ids[]", $0) } + + excludeAssignmentSubmissionTypes.map { ("exclude_assignment_submission_types[]", $0) } + } + + // MARK: Query Params + let include: [String] + let assignmentIds: [String] + let excludeAssignmentSubmissionTypes: [String] + let overrideAssignmentDates: Bool? + let gradingPeriodId: Int? + let scopeAssignmentsToStudent: Bool? + + // Initializer + init( + courseId: String, + include: [String] = [], + assignmentIds: [String] = [], + excludeAssignmentSubmissionTypes: [String] = [], + overrideAssignmentDates: Bool? = nil, + gradingPeriodId: Int? = nil, + scopeAssignmentsToStudent: Bool? = nil + ) { + self.courseId = courseId + self.include = include + self.assignmentIds = assignmentIds + self.excludeAssignmentSubmissionTypes = excludeAssignmentSubmissionTypes + self.overrideAssignmentDates = overrideAssignmentDates + self.gradingPeriodId = gradingPeriodId + self.scopeAssignmentsToStudent = scopeAssignmentsToStudent + } + + /* MARK: Request Caching (Optional Implementation) + var requestId: Int? { courseId.asInt } + var requestIdKey: ParentKeyPath { .createReadable(\.courseId) } + var idPredicate: Predicate { + #Predicate { group in + group.courseId == requestId + } + } + + var customPredicate: Predicate { + let ids = assignmentIds.compactMap(\.?.asInt) + let assignmentIdsPred = assignmentIds.isEmpty ? .true : #Predicate { group in + ids.contains(group.id) + } + return assignmentIdsPred + } + */ +} diff --git a/CanvasPlusPlayground/Common/Network/CanvasRequest.swift b/CanvasPlusPlayground/Common/Network/CanvasRequest.swift index 3ae1043..b71f522 100644 --- a/CanvasPlusPlayground/Common/Network/CanvasRequest.swift +++ b/CanvasPlusPlayground/Common/Network/CanvasRequest.swift @@ -39,7 +39,14 @@ struct CanvasRequest { static func getAssignments(courseId: String) -> GetAssignmentsRequest { GetAssignmentsRequest(courseId: courseId) } - + + static func getAssignmentGroups( + courseId: String, + include includeItems: [String] = ["assignments", "submission"] + ) -> GetAssignmentGroupsRequest { + GetAssignmentGroupsRequest(courseId: courseId, include: includeItems) + } + static func getEnrollments(courseId: String, userId: Int? = nil, perPage: Int = 50) -> GetEnrollmentsRequest { GetEnrollmentsRequest(courseId: courseId, userId: userId?.asString, perPage: perPage) } 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/CourseAssignmentManager.swift b/CanvasPlusPlayground/Features/Assignments/CourseAssignmentManager.swift index d47b424..0c73297 100644 --- a/CanvasPlusPlayground/Features/Assignments/CourseAssignmentManager.swift +++ b/CanvasPlusPlayground/Features/Assignments/CourseAssignmentManager.swift @@ -10,28 +10,39 @@ import SwiftUI @Observable class CourseAssignmentManager { private let courseID: String? - var assignments = [Assignment]() + var assignmentGroups: [AssignmentGroup] = [] init(courseID: String?) { self.courseID = courseID } - func fetchAssignments() async { - guard let courseID = courseID, let (data, _) = try? await CanvasService.shared.fetchResponse(CanvasRequest.getAssignments(courseId: courseID)) else { - print("Failed to fetch assignments.") + func fetchAssignmentGroups() async { + guard let courseID = courseID, let (data, _) = try? await CanvasService.shared.fetchResponse( + CanvasRequest.getAssignmentGroups(courseId: courseID) + ) else { + print("Failed to fetch assignment groups.") return } + self.assignmentGroups = (try? JSONDecoder().decode([AssignmentGroup].self, from: data)) ?? [] + } + + static func getAssignmentsForCourse(courseID: String) async -> [Assignment] { + await CourseAssignmentManager.fetchAssignments(courseID: courseID) + } + + private static func fetchAssignments(courseID: String) async -> [Assignment] { + guard let (data, _) = try? await CanvasService.shared.fetchResponse(CanvasRequest.getAssignments(courseId: courseID)) else { + print("Failed to fetch assignments.") + return [] + } + do { - self.assignments = try JSONDecoder().decode([Assignment].self, from: data) + return try JSONDecoder().decode([Assignment].self, from: data) } catch { print(error) } - } - - static func getAssignmentsForCourse(courseID: String) async -> [Assignment] { - let manager = CourseAssignmentManager(courseID: courseID) - await manager.fetchAssignments() - return manager.assignments + + return [] } } diff --git a/CanvasPlusPlayground/Features/Assignments/CourseAssignmentView.swift b/CanvasPlusPlayground/Features/Assignments/CourseAssignmentView.swift index 8b9719a..495c9cc 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 @@ -21,27 +22,20 @@ struct CourseAssignmentsView: View { } var body: some View { - List(assignmentManager.assignments, id: \.id) { assignment in - HStack { - VStack(alignment: .leading) { - Text(assignment.name) - .font(.headline) - Group { - if let submission = assignment.submission { - Text( - submission.workflowState?.rawValue.capitalized ?? "Unknown Status" - ) - } - } - .font(.subheadline) + List(assignmentManager.assignmentGroups) { assignmentGroup in + Section { + ForEach(assignmentGroup.assignments ?? []) { assignment in + AssignmentRow(assignment: assignment, showGrades: showGrades) } + } header: { + HStack { + Text(assignmentGroup.name ?? "") - if showGrades, let submission = assignment.submission { Spacer() - Text(submission.score?.truncatingTrailingZeros ?? "--") + - Text("/") + - Text(assignment.pointsPossible?.truncatingTrailingZeros ?? "--") + if let groupWeight = assignmentGroup.groupWeight { + Text("\(groupWeight)%") + } } } } @@ -50,11 +44,52 @@ 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 { isLoadingAssignments = true - await assignmentManager.fetchAssignments() + await assignmentManager.fetchAssignmentGroups() isLoadingAssignments = false } } + +private struct AssignmentRow: View { + let assignment: Assignment + let showGrades: Bool + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(assignment.name) + .font(.headline) + Group { + if let submission = assignment.submission { + Text( + submission.workflowState?.rawValue.capitalized ?? "Unknown Status" + ) + } + } + .font(.subheadline) + } + + if showGrades, let submission = assignment.submission { + Spacer() + + Text(submission.score?.truncatingTrailingZeros ?? "--") + + Text("/") + + Text(assignment.pointsPossible?.truncatingTrailingZeros ?? "--") + } + } + } +} diff --git a/CanvasPlusPlayground/Features/Grades/CourseGradeView.swift b/CanvasPlusPlayground/Features/Grades/CourseGradeView.swift index a14a653..4bd6885 100644 --- a/CanvasPlusPlayground/Features/Grades/CourseGradeView.swift +++ b/CanvasPlusPlayground/Features/Grades/CourseGradeView.swift @@ -31,9 +31,12 @@ struct CourseGradeView: View { Group { if let url = gradesVM.canvasURL { Link("View on Canvas", destination: url) - } else { + } else if gradesVM.isLoading { Text("Loading grades...") .foregroundStyle(.secondary) + } else { + Text("Unable to load grades") // TODO: Display a relevant error message + .foregroundStyle(.secondary) } } .font(.footnote) diff --git a/CanvasPlusPlayground/Features/Grades/GradeCalculator/CourseGradeCalculator.swift b/CanvasPlusPlayground/Features/Grades/GradeCalculator/CourseGradeCalculator.swift new file mode 100644 index 0000000..3fede9e --- /dev/null +++ b/CanvasPlusPlayground/Features/Grades/GradeCalculator/CourseGradeCalculator.swift @@ -0,0 +1,98 @@ +// +// CourseGradeCalculator.swift +// CanvasPlusPlayground +// +// Created by Rahul on 12/19/24. +// + +import Foundation + +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/CourseGradeCalculatorBridge.swift b/CanvasPlusPlayground/Features/Grades/GradeCalculator/CourseGradeCalculatorBridge.swift new file mode 100644 index 0000000..353479f --- /dev/null +++ b/CanvasPlusPlayground/Features/Grades/GradeCalculator/CourseGradeCalculatorBridge.swift @@ -0,0 +1,38 @@ +// +// CourseGradeCalculatorBridge.swift +// CanvasPlusPlayground +// +// Created by Rahul on 12/20/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) } + ) + } +} 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 + } + } +} diff --git a/CanvasPlusPlayground/Features/Grades/GradesViewModel.swift b/CanvasPlusPlayground/Features/Grades/GradesViewModel.swift index cd5d015..0de76c6 100644 --- a/CanvasPlusPlayground/Features/Grades/GradesViewModel.swift +++ b/CanvasPlusPlayground/Features/Grades/GradesViewModel.swift @@ -36,7 +36,9 @@ class GradesViewModel { } let courseId: String - + + var isLoading = true + init(courseId: String) { self.courseId = courseId } @@ -47,27 +49,32 @@ class GradesViewModel { return } - let request = CanvasRequest.getEnrollments(courseId: courseId) - + isLoading = true + + let request = CanvasRequest.getEnrollments( + courseId: courseId, + userId: currentUserID + ) + do { let enrollments: [Enrollment]? = try await CanvasService.shared.loadAndSync(request, onCacheReceive: { enrollmentsCache in guard let enrollmentsCache else { return } - findEnrollment( + setEnrollment( enrollments: enrollmentsCache, currentUserID: currentUserID ) }, onNewBatch: { enrollmentsBatch in - findEnrollment( + setEnrollment( enrollments: enrollmentsBatch, currentUserID: currentUserID ) }) if let enrollments { - findEnrollment( + setEnrollment( enrollments: enrollments, currentUserID: currentUserID ) @@ -75,17 +82,20 @@ class GradesViewModel { } catch { print("Failed to fetch enrollments. \(error)") } + + isLoading = false } - /// Searches for the users enrollment and sets it if found - func findEnrollment(enrollments: [Enrollment], currentUserID: Int) { - let newEnrollment = enrollments - .first { $0.userID == currentUserID } - - if newEnrollment != nil { - DispatchQueue.main.async { - self.enrollment = newEnrollment - } + /// Sets user enrollment if found. + private func setEnrollment(enrollments: [Enrollment], currentUserID: Int) { + guard enrollments.count == 1, + let first = enrollments.first, + first.userID == currentUserID else { + return + } + + DispatchQueue.main.async { + self.enrollment = first } } diff --git a/CanvasPlusPlayground/Schema/Plain/Assignment.swift b/CanvasPlusPlayground/Schema/Plain/Assignment.swift index a03b737..9281c53 100644 --- a/CanvasPlusPlayground/Schema/Plain/Assignment.swift +++ b/CanvasPlusPlayground/Schema/Plain/Assignment.swift @@ -73,7 +73,7 @@ import Foundation "restrict_quantitative_data":false} */ -struct Assignment: Codable { +struct Assignment: Codable, Identifiable { let id: Int let description: String? let dueAt: String? diff --git a/CanvasPlusPlayground/Schema/Plain/AssignmentGroup.swift b/CanvasPlusPlayground/Schema/Plain/AssignmentGroup.swift new file mode 100644 index 0000000..792553d --- /dev/null +++ b/CanvasPlusPlayground/Schema/Plain/AssignmentGroup.swift @@ -0,0 +1,42 @@ +// +// AssignmentGroup.swift +// CanvasPlusPlayground +// +// Created by Rahul on 12/18/24. +// + +import Foundation + +struct AssignmentGroup: Codable, Identifiable { + let id: Int? + let name: String? + let position: Int? + let groupWeight: Int? + let sisSourceID: String? + let integrationData: [String: String]? + let assignments: [Assignment]? + let rules: GradingRules? + + enum CodingKeys: String, CodingKey { + case id + case name + case position + case groupWeight = "group_weight" + case sisSourceID = "sis_source_id" + case integrationData = "integration_data" + case assignments + case rules + } +} + +struct GradingRules: Codable { + let dropLowest: Int? + let dropHighest: Int? + let neverDrop: [Int]? + + enum CodingKeys: String, CodingKey { + case dropLowest = "drop_lowest" + case dropHighest = "drop_highest" + case neverDrop = "never_drop" + } +}