Kaynağa Gözat

Add enrolled classes page with Classroom roster support.

Introduce a new sidebar Enrolled page that mirrors schedule styling, fetches active courses for the signed-in user, and shows teacher names via Classroom roster APIs.

Made-with: Cursor
huzaifahayat12 1 hafta önce
ebeveyn
işleme
0e507d2136

+ 2 - 1
classroom_app/Auth/GoogleOAuthService.swift

@@ -47,7 +47,8 @@ final class GoogleOAuthService: NSObject {
47 47
         "https://www.googleapis.com/auth/calendar.events",
48 48
         // Classroom To-do (assignments/quizzes)
49 49
         "https://www.googleapis.com/auth/classroom.courses.readonly",
50
-        "https://www.googleapis.com/auth/classroom.coursework.me.readonly"
50
+        "https://www.googleapis.com/auth/classroom.coursework.me.readonly",
51
+        "https://www.googleapis.com/auth/classroom.rosters.readonly"
51 52
     ]
52 53
 
53 54
     private let tokenStore = KeychainTokenStore()

+ 85 - 0
classroom_app/Google/GoogleClassroomClient.swift

@@ -74,6 +74,42 @@ final class GoogleClassroomClient {
74 74
         return todos
75 75
     }
76 76
 
77
+    func fetchEnrolledCourses(accessToken: String, pageSize: Int = 50) async throws -> [ClassroomCourse] {
78
+        let courses = try await listActiveCourses(accessToken: accessToken, pageSize: pageSize)
79
+        if courses.isEmpty { return [] }
80
+
81
+        var enrolled: [ClassroomCourse] = []
82
+        enrolled.reserveCapacity(courses.count)
83
+        for course in courses {
84
+            let teachers = try await listCourseTeachers(accessToken: accessToken, courseId: course.id)
85
+            let teacherNames = teachers.compactMap { teacher -> String? in
86
+                let profileName = teacher.profile?.name?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines)
87
+                if let profileName, profileName.isEmpty == false { return profileName }
88
+                let email = teacher.profile?.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines)
89
+                if let email, email.isEmpty == false { return email }
90
+                return nil
91
+            }
92
+
93
+            enrolled.append(
94
+                ClassroomCourse(
95
+                    id: course.id,
96
+                    name: (course.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (course.name ?? "Course") : "Course",
97
+                    section: course.section,
98
+                    room: course.room,
99
+                    teacherNames: teacherNames,
100
+                    enrollmentCode: course.enrollmentCode,
101
+                    courseState: course.courseState ?? "ACTIVE"
102
+                )
103
+            )
104
+        }
105
+
106
+        enrolled.sort {
107
+            if $0.name != $1.name { return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
108
+            return $0.id < $1.id
109
+        }
110
+        return enrolled
111
+    }
112
+
77 113
     // MARK: - Courses
78 114
 
79 115
     private func listActiveCourses(accessToken: String, pageSize: Int) async throws -> [Course] {
@@ -105,6 +141,33 @@ final class GoogleClassroomClient {
105 141
         return decoded.courses ?? []
106 142
     }
107 143
 
144
+    private func listCourseTeachers(accessToken: String, courseId: String) async throws -> [Teacher] {
145
+        var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/teachers")!
146
+        components.queryItems = [
147
+            URLQueryItem(name: "pageSize", value: "30")
148
+        ]
149
+
150
+        var request = URLRequest(url: components.url!)
151
+        request.httpMethod = "GET"
152
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
153
+
154
+        let (data, response) = try await session.data(for: request)
155
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
156
+        guard (200..<300).contains(http.statusCode) else {
157
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
158
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
159
+        }
160
+
161
+        let decoded: TeachersList
162
+        do {
163
+            decoded = try JSONDecoder().decode(TeachersList.self, from: data)
164
+        } catch {
165
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
166
+            throw GoogleClassroomClientError.decodeFailed(raw)
167
+        }
168
+        return decoded.teachers ?? []
169
+    }
170
+
108 171
     // MARK: - CourseWork
109 172
 
110 173
     private func listPublishedCourseWork(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseWork] {
@@ -160,6 +223,10 @@ private struct CoursesList: Decodable {
160 223
 private struct Course: Decodable {
161 224
     let id: String
162 225
     let name: String?
226
+    let section: String?
227
+    let room: String?
228
+    let enrollmentCode: String?
229
+    let courseState: String?
163 230
 }
164 231
 
165 232
 private struct CourseWorkList: Decodable {
@@ -208,3 +275,21 @@ private struct TimeOfDay: Decodable {
208 275
     let seconds: Int?
209 276
     let nanos: Int?
210 277
 }
278
+
279
+private struct TeachersList: Decodable {
280
+    let teachers: [Teacher]?
281
+    let nextPageToken: String?
282
+}
283
+
284
+private struct Teacher: Decodable {
285
+    let profile: UserProfile?
286
+}
287
+
288
+private struct UserProfile: Decodable {
289
+    let name: UserName?
290
+    let emailAddress: String?
291
+}
292
+
293
+private struct UserName: Decodable {
294
+    let fullName: String?
295
+}

+ 11 - 0
classroom_app/Models/ClassroomCourse.swift

@@ -0,0 +1,11 @@
1
+import Foundation
2
+
3
+struct ClassroomCourse: Identifiable, Equatable {
4
+    let id: String
5
+    let name: String
6
+    let section: String?
7
+    let room: String?
8
+    let teacherNames: [String]
9
+    let enrollmentCode: String?
10
+    let courseState: String
11
+}

+ 265 - 3
classroom_app/ViewController.swift

@@ -14,8 +14,9 @@ import StoreKit
14 14
 private enum SidebarPage: Int {
15 15
     case joinMeetings = 0
16 16
     case photo = 1
17
-    case video = 2
18
-    case settings = 3
17
+    case enrolled = 2
18
+    case video = 3
19
+    case settings = 4
19 20
 }
20 21
 
21 22
 private enum ZoomJoinMode: Int {
@@ -287,6 +288,7 @@ final class ViewController: NSViewController {
287 288
     private var hasViewAppearedOnce = false
288 289
     private var lastKnownPremiumAccess = false
289 290
     private var displayedScheduleTodos: [ClassroomTodoItem] = []
291
+    private var enrolledCachedCourses: [ClassroomCourse] = []
290 292
     private var appUsageSessionStartDate: Date?
291 293
     private var hasObservedAppLifecycleForUsage = false
292 294
     private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
@@ -356,6 +358,8 @@ final class ViewController: NSViewController {
356 358
     private weak var schedulePageRangeErrorLabel: NSTextField?
357 359
     private weak var schedulePageCardsStack: NSStackView?
358 360
     private weak var schedulePageCardsScrollView: NSScrollView?
361
+    private weak var enrolledPageHeadingLabel: NSTextField?
362
+    private weak var enrolledPageCardsStack: NSStackView?
359 363
 
360 364
     // MARK: - Calendar page (custom month UI)
361 365
     private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
@@ -1262,6 +1266,7 @@ private extension ViewController {
1262 1266
     private func refreshPagesAfterPremiumStateUpdate() {
1263 1267
         pageCache[.joinMeetings] = nil
1264 1268
         pageCache[.photo] = nil
1269
+        pageCache[.enrolled] = nil
1265 1270
         pageCache[.video] = nil
1266 1271
         pageCache[.settings] = nil
1267 1272
         showSidebarPage(selectedSidebarPage)
@@ -1508,6 +1513,8 @@ private extension ViewController {
1508 1513
             built = makeJoinMeetingsContent()
1509 1514
         case .photo:
1510 1515
             built = makeSchedulePageContent()
1516
+        case .enrolled:
1517
+            built = makeEnrolledPageContent()
1511 1518
         case .video:
1512 1519
             built = makeCalendarPageContent()
1513 1520
         case .settings:
@@ -1866,6 +1873,8 @@ private extension ViewController {
1866 1873
             title = "App for Google Classroom"
1867 1874
         case .photo:
1868 1875
             title = "Schedule"
1876
+        case .enrolled:
1877
+            title = "Enrolled"
1869 1878
         case .video:
1870 1879
             title = "Calendar"
1871 1880
         case .settings:
@@ -1909,7 +1918,7 @@ private extension ViewController {
1909 1918
     private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
1910 1919
         switch page {
1911 1920
         case .photo: return false
1912
-        case .joinMeetings, .video, .settings: return true
1921
+        case .joinMeetings, .enrolled, .video, .settings: return true
1913 1922
         }
1914 1923
     }
1915 1924
 
@@ -1964,6 +1973,9 @@ private extension ViewController {
1964 1973
         let photoRow = sidebarItem("Schedule", icon: "􀏂", page: .photo, systemSymbolName: "clock.badge.checkmark")
1965 1974
         menuStack.addArrangedSubview(photoRow)
1966 1975
         sidebarRowViews[.photo] = photoRow
1976
+        let enrolledRow = sidebarItem("Enrolled", icon: "􀆄", page: .enrolled, systemSymbolName: "person.3.sequence.fill")
1977
+        menuStack.addArrangedSubview(enrolledRow)
1978
+        sidebarRowViews[.enrolled] = enrolledRow
1967 1979
         let videoRow = sidebarItem("Calendar", icon: "􀎚", page: .video, systemSymbolName: "calendar")
1968 1980
         menuStack.addArrangedSubview(videoRow)
1969 1981
         sidebarRowViews[.video] = videoRow
@@ -2201,6 +2213,109 @@ private extension ViewController {
2201 2213
         return panel
2202 2214
     }
2203 2215
 
2216
+    func makeEnrolledPageContent() -> NSView {
2217
+        let panel = NSView()
2218
+        panel.translatesAutoresizingMaskIntoConstraints = false
2219
+        panel.userInterfaceLayoutDirection = .leftToRight
2220
+
2221
+        let contentStack = NSStackView()
2222
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
2223
+        contentStack.userInterfaceLayoutDirection = .leftToRight
2224
+        contentStack.orientation = .vertical
2225
+        contentStack.spacing = 14
2226
+        contentStack.alignment = .width
2227
+        contentStack.distribution = .fill
2228
+
2229
+        let titleRow = NSStackView()
2230
+        titleRow.translatesAutoresizingMaskIntoConstraints = false
2231
+        titleRow.userInterfaceLayoutDirection = .leftToRight
2232
+        titleRow.orientation = .horizontal
2233
+        titleRow.alignment = .centerY
2234
+        titleRow.distribution = .fill
2235
+        titleRow.spacing = 10
2236
+
2237
+        let titleLabel = textLabel("Enrolled Classes", font: typography.pageTitle, color: palette.textPrimary)
2238
+        titleLabel.alignment = .left
2239
+        titleLabel.maximumNumberOfLines = 1
2240
+        titleLabel.lineBreakMode = .byTruncatingTail
2241
+        titleLabel.setContentHuggingPriority(.required, for: .horizontal)
2242
+        titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
2243
+
2244
+        let spacer = NSView()
2245
+        spacer.translatesAutoresizingMaskIntoConstraints = false
2246
+        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
2247
+        spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2248
+
2249
+        let refreshButton = makeScheduleRefreshButton()
2250
+        refreshButton.target = self
2251
+        refreshButton.action = #selector(enrolledPageRefreshPressed(_:))
2252
+
2253
+        titleRow.addArrangedSubview(titleLabel)
2254
+        titleRow.addArrangedSubview(spacer)
2255
+        titleRow.addArrangedSubview(refreshButton)
2256
+
2257
+        let heading = textLabel(enrolledPageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
2258
+        heading.alignment = .left
2259
+        heading.maximumNumberOfLines = 2
2260
+        heading.lineBreakMode = .byWordWrapping
2261
+        enrolledPageHeadingLabel = heading
2262
+
2263
+        let scroll = NSScrollView()
2264
+        scroll.translatesAutoresizingMaskIntoConstraints = false
2265
+        scroll.userInterfaceLayoutDirection = .leftToRight
2266
+        scroll.drawsBackground = false
2267
+        scroll.hasHorizontalScroller = false
2268
+        scroll.hasVerticalScroller = true
2269
+        scroll.autohidesScrollers = true
2270
+        scroll.borderType = .noBorder
2271
+        scroll.scrollerStyle = .overlay
2272
+        scroll.automaticallyAdjustsContentInsets = false
2273
+        let clip = TopAlignedClipView()
2274
+        clip.drawsBackground = false
2275
+        scroll.contentView = clip
2276
+
2277
+        let stack = NSStackView()
2278
+        stack.translatesAutoresizingMaskIntoConstraints = false
2279
+        stack.userInterfaceLayoutDirection = .leftToRight
2280
+        stack.orientation = .vertical
2281
+        stack.spacing = 14
2282
+        stack.alignment = .width
2283
+        enrolledPageCardsStack = stack
2284
+        scroll.documentView = stack
2285
+
2286
+        NSLayoutConstraint.activate([
2287
+            stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
2288
+            stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
2289
+            stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
2290
+            stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor)
2291
+        ])
2292
+
2293
+        contentStack.addArrangedSubview(titleRow)
2294
+        contentStack.setCustomSpacing(10, after: titleRow)
2295
+        contentStack.addArrangedSubview(heading)
2296
+        contentStack.setCustomSpacing(12, after: heading)
2297
+        contentStack.addArrangedSubview(scroll)
2298
+        panel.addSubview(contentStack)
2299
+
2300
+        NSLayoutConstraint.activate([
2301
+            contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28),
2302
+            contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28),
2303
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
2304
+            contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
2305
+            titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
2306
+            heading.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
2307
+            scroll.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
2308
+            scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420)
2309
+        ])
2310
+
2311
+        renderEnrolledClassCards([])
2312
+        Task { [weak self] in
2313
+            await self?.loadEnrolledClasses()
2314
+        }
2315
+
2316
+        return panel
2317
+    }
2318
+
2204 2319
     func makeCalendarPageContent() -> NSView {
2205 2320
         let panel = NSView()
2206 2321
         panel.translatesAutoresizingMaskIntoConstraints = false
@@ -5845,6 +5960,16 @@ private extension ViewController {
5845 5960
         googleOAuth.loadTokens() == nil ? "Connect Google to see your to-do" : "Loading to-do…"
5846 5961
     }
5847 5962
 
5963
+    private func enrolledPageInitialHeadingText() -> String {
5964
+        googleOAuth.loadTokens() == nil ? "Connect Google to see enrolled classes" : "Loading classes…"
5965
+    }
5966
+
5967
+    @objc func enrolledPageRefreshPressed(_ sender: NSButton) {
5968
+        Task { [weak self] in
5969
+            await self?.loadEnrolledClasses()
5970
+        }
5971
+    }
5972
+
5848 5973
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
5849 5974
         guard let selectedItem = sender.selectedItem,
5850 5975
               let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
@@ -5950,10 +6075,102 @@ private extension ViewController {
5950 6075
         return f.string(from: day)
5951 6076
     }
5952 6077
 
6078
+    private func enrolledPageHeadingText(for courses: [ClassroomCourse]) -> String {
6079
+        if googleOAuth.loadTokens() == nil { return "Connect Google to see enrolled classes" }
6080
+        if courses.isEmpty { return "No active enrolled classes" }
6081
+        return "\(courses.count) enrolled class\(courses.count == 1 ? "" : "es")"
6082
+    }
6083
+
5953 6084
     private func openURL(_ url: URL) {
5954 6085
         NSWorkspace.shared.open(url)
5955 6086
     }
5956 6087
 
6088
+    private func renderEnrolledClassCards(_ courses: [ClassroomCourse]) {
6089
+        guard let stack = enrolledPageCardsStack else { return }
6090
+
6091
+        stack.arrangedSubviews.forEach { view in
6092
+            stack.removeArrangedSubview(view)
6093
+            view.removeFromSuperview()
6094
+        }
6095
+
6096
+        if courses.isEmpty {
6097
+            let empty = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
6098
+            empty.translatesAutoresizingMaskIntoConstraints = false
6099
+            empty.heightAnchor.constraint(equalToConstant: 128).isActive = true
6100
+            styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
6101
+
6102
+            let emptyLabel = textLabel(
6103
+                googleOAuth.loadTokens() == nil ? "Connect to load classes" : "No enrolled classes found",
6104
+                font: typography.cardSubtitle,
6105
+                color: palette.textSecondary
6106
+            )
6107
+            emptyLabel.translatesAutoresizingMaskIntoConstraints = false
6108
+            emptyLabel.alignment = .left
6109
+            empty.addSubview(emptyLabel)
6110
+
6111
+            NSLayoutConstraint.activate([
6112
+                emptyLabel.leadingAnchor.constraint(equalTo: empty.leadingAnchor, constant: 18),
6113
+                emptyLabel.trailingAnchor.constraint(equalTo: empty.trailingAnchor, constant: -18),
6114
+                emptyLabel.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
6115
+            ])
6116
+            stack.addArrangedSubview(empty)
6117
+            return
6118
+        }
6119
+
6120
+        for course in courses {
6121
+            stack.addArrangedSubview(enrolledClassCard(course: course))
6122
+        }
6123
+    }
6124
+
6125
+    private func enrolledClassCard(course: ClassroomCourse) -> NSView {
6126
+        let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
6127
+        card.translatesAutoresizingMaskIntoConstraints = false
6128
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
6129
+        card.heightAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true
6130
+
6131
+        let title = textLabel(course.name, font: NSFont.systemFont(ofSize: 17, weight: .semibold), color: palette.textPrimary)
6132
+        title.translatesAutoresizingMaskIntoConstraints = false
6133
+        title.alignment = .left
6134
+        title.maximumNumberOfLines = 2
6135
+        title.lineBreakMode = .byWordWrapping
6136
+
6137
+        let sectionText = [course.section, course.room]
6138
+            .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
6139
+            .filter { !$0.isEmpty }
6140
+            .joined(separator: " • ")
6141
+        let sectionLabel = textLabel(sectionText.isEmpty ? "Section details unavailable" : sectionText, font: typography.cardSubtitle, color: palette.textSecondary)
6142
+        sectionLabel.translatesAutoresizingMaskIntoConstraints = false
6143
+        sectionLabel.alignment = .left
6144
+        sectionLabel.maximumNumberOfLines = 1
6145
+        sectionLabel.lineBreakMode = .byTruncatingTail
6146
+
6147
+        let teachers = course.teacherNames.isEmpty ? "Teacher unavailable" : course.teacherNames.joined(separator: ", ")
6148
+        let teacherLabel = textLabel("Teacher: \(teachers)", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textMuted)
6149
+        teacherLabel.translatesAutoresizingMaskIntoConstraints = false
6150
+        teacherLabel.alignment = .left
6151
+        teacherLabel.maximumNumberOfLines = 2
6152
+        teacherLabel.lineBreakMode = .byWordWrapping
6153
+
6154
+        card.addSubview(title)
6155
+        card.addSubview(sectionLabel)
6156
+        card.addSubview(teacherLabel)
6157
+        NSLayoutConstraint.activate([
6158
+            title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
6159
+            title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
6160
+            title.topAnchor.constraint(equalTo: card.topAnchor, constant: 16),
6161
+
6162
+            sectionLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
6163
+            sectionLabel.trailingAnchor.constraint(equalTo: title.trailingAnchor),
6164
+            sectionLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8),
6165
+
6166
+            teacherLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
6167
+            teacherLabel.trailingAnchor.constraint(equalTo: title.trailingAnchor),
6168
+            teacherLabel.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 10),
6169
+            teacherLabel.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -16)
6170
+        ])
6171
+        return card
6172
+    }
6173
+
5957 6174
     private func renderScheduleCards(into stack: NSStackView, todos: [ClassroomTodoItem]) {
5958 6175
         displayedScheduleTodos = todos
5959 6176
         let shouldShowScrollControls = todos.count > 3
@@ -6293,6 +6510,48 @@ private extension ViewController {
6293 6510
         }
6294 6511
     }
6295 6512
 
6513
+    private func loadEnrolledClasses() async {
6514
+        do {
6515
+            if googleOAuth.loadTokens() == nil {
6516
+                await MainActor.run {
6517
+                    enrolledCachedCourses = []
6518
+                    enrolledPageHeadingLabel?.stringValue = "Connect Google to see enrolled classes"
6519
+                    renderEnrolledClassCards([])
6520
+                }
6521
+                return
6522
+            }
6523
+
6524
+            let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
6525
+            let courses = try await classroomClient.fetchEnrolledCourses(accessToken: token)
6526
+
6527
+            await MainActor.run {
6528
+                enrolledCachedCourses = courses
6529
+                enrolledPageHeadingLabel?.stringValue = enrolledPageHeadingText(for: courses)
6530
+                renderEnrolledClassCards(courses)
6531
+            }
6532
+        } catch {
6533
+            await MainActor.run {
6534
+                if errorRequiresReconsentForClassroomScopes(error) {
6535
+                    _ = try? googleOAuth.signOut()
6536
+                    applyGoogleProfile(nil)
6537
+                    updateGoogleAuthButtonTitle()
6538
+                    enrolledCachedCourses = []
6539
+                    enrolledPageHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions"
6540
+                    renderEnrolledClassCards([])
6541
+                    showSimpleAlert(
6542
+                        title: "Reconnect Google",
6543
+                        message: "We added Google Classroom permissions. Please connect your Google account again so Google can grant access to your classes."
6544
+                    )
6545
+                    return
6546
+                }
6547
+                enrolledCachedCourses = []
6548
+                enrolledPageHeadingLabel?.stringValue = "Couldn’t load enrolled classes"
6549
+                renderEnrolledClassCards([])
6550
+                showSimpleError("Couldn’t load enrolled classes.", error: error)
6551
+            }
6552
+        }
6553
+    }
6554
+
6296 6555
     func showScheduleHelp() {
6297 6556
         let alert = NSAlert()
6298 6557
         alert.messageText = "Google Classroom to-do"
@@ -6311,6 +6570,7 @@ private extension ViewController {
6311 6570
                     self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
6312 6571
                     self.pageCache[.joinMeetings] = nil
6313 6572
                     self.pageCache[.photo] = nil
6573
+                    self.pageCache[.enrolled] = nil
6314 6574
                     self.showSidebarPage(self.selectedSidebarPage)
6315 6575
                 }
6316 6576
                 await self.loadSchedule()
@@ -6339,6 +6599,7 @@ private extension ViewController {
6339 6599
                     self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
6340 6600
                     self.pageCache[.joinMeetings] = nil
6341 6601
                     self.pageCache[.photo] = nil
6602
+                    self.pageCache[.enrolled] = nil
6342 6603
                     self.pageCache[.video] = nil
6343 6604
                     self.pageCache[.settings] = nil
6344 6605
                     self.showSidebarPage(self.selectedSidebarPage)
@@ -6390,6 +6651,7 @@ private extension ViewController {
6390 6651
             updateGoogleAuthButtonTitle()
6391 6652
             pageCache[.joinMeetings] = nil
6392 6653
             pageCache[.photo] = nil
6654
+            pageCache[.enrolled] = nil
6393 6655
             pageCache[.video] = nil
6394 6656
             pageCache[.settings] = nil
6395 6657
             showSidebarPage(selectedSidebarPage)