Sfoglia il codice sorgente

Add a Teaching page that mirrors Enrolled with the same class-card and details-popup behavior while loading courses taught by the signed-in user.

Also make single class cards span full width, rename the sidebar Schedule label to To-Do, and tolerate Classroom permission-denied detail sections so popups still open with available content.

Made-with: Cursor
huzaifahayat12 1 settimana fa
parent
commit
b9d77b33cb

+ 88 - 1
classroom_app/Google/GoogleClassroomClient.swift

@@ -110,10 +110,60 @@ final class GoogleClassroomClient {
110 110
         return enrolled
111 111
     }
112 112
 
113
+    func fetchTeachingCourses(accessToken: String, pageSize: Int = 50) async throws -> [ClassroomCourse] {
114
+        let courses = try await listTeachingActiveCourses(accessToken: accessToken, pageSize: pageSize)
115
+        if courses.isEmpty { return [] }
116
+
117
+        var teaching: [ClassroomCourse] = []
118
+        teaching.reserveCapacity(courses.count)
119
+        for course in courses {
120
+            let teachers = try await listCourseTeachers(accessToken: accessToken, courseId: course.id)
121
+            let teacherNames = teachers.compactMap { teacher -> String? in
122
+                let profileName = teacher.profile?.name?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines)
123
+                if let profileName, profileName.isEmpty == false { return profileName }
124
+                let email = teacher.profile?.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines)
125
+                if let email, email.isEmpty == false { return email }
126
+                return nil
127
+            }
128
+
129
+            teaching.append(
130
+                ClassroomCourse(
131
+                    id: course.id,
132
+                    name: (course.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (course.name ?? "Course") : "Course",
133
+                    section: course.section,
134
+                    room: course.room,
135
+                    teacherNames: teacherNames,
136
+                    enrollmentCode: course.enrollmentCode,
137
+                    courseState: course.courseState ?? "ACTIVE"
138
+                )
139
+            )
140
+        }
141
+
142
+        teaching.sort {
143
+            if $0.name != $1.name { return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
144
+            return $0.id < $1.id
145
+        }
146
+        return teaching
147
+    }
148
+
113 149
     func fetchClassDetails(accessToken: String, courseId: String, courseName: String) async throws -> ClassroomClassDetails {
114 150
         async let announcementsTask = listCourseAnnouncements(accessToken: accessToken, courseId: courseId, pageSize: 30)
115 151
         async let courseworkTask = listPublishedCourseWork(accessToken: accessToken, courseId: courseId, pageSize: 30)
116
-        let (announcements, coursework) = try await (announcementsTask, courseworkTask)
152
+
153
+        let announcements: [CourseAnnouncement]
154
+        let coursework: [CourseWork]
155
+
156
+        do {
157
+            announcements = try await announcementsTask
158
+        } catch let err as GoogleClassroomClientError where err.isPermissionDenied {
159
+            announcements = []
160
+        }
161
+
162
+        do {
163
+            coursework = try await courseworkTask
164
+        } catch let err as GoogleClassroomClientError where err.isPermissionDenied {
165
+            coursework = []
166
+        }
117 167
 
118 168
         let mappedAnnouncements = announcements.map { item in
119 169
             let text = item.text?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -180,6 +230,35 @@ final class GoogleClassroomClient {
180 230
         return decoded.courses ?? []
181 231
     }
182 232
 
233
+    private func listTeachingActiveCourses(accessToken: String, pageSize: Int) async throws -> [Course] {
234
+        var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses")!
235
+        components.queryItems = [
236
+            URLQueryItem(name: "teacherId", value: "me"),
237
+            URLQueryItem(name: "courseStates", value: "ACTIVE"),
238
+            URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
239
+        ]
240
+
241
+        var request = URLRequest(url: components.url!)
242
+        request.httpMethod = "GET"
243
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
244
+
245
+        let (data, response) = try await session.data(for: request)
246
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
247
+        guard (200..<300).contains(http.statusCode) else {
248
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
249
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
250
+        }
251
+
252
+        let decoded: CoursesList
253
+        do {
254
+            decoded = try JSONDecoder().decode(CoursesList.self, from: data)
255
+        } catch {
256
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
257
+            throw GoogleClassroomClientError.decodeFailed(raw)
258
+        }
259
+        return decoded.courses ?? []
260
+    }
261
+
183 262
     private func listCourseTeachers(accessToken: String, courseId: String) async throws -> [Teacher] {
184 263
         var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/teachers")!
185 264
         components.queryItems = [
@@ -328,6 +407,14 @@ final class GoogleClassroomClient {
328 407
     }
329 408
 }
330 409
 
410
+private extension GoogleClassroomClientError {
411
+    var isPermissionDenied: Bool {
412
+        guard case let .httpStatus(status, body) = self, status == 403 else { return false }
413
+        let lowercasedBody = body.lowercased()
414
+        return lowercasedBody.contains("permission_denied") || lowercasedBody.contains("does not have permission")
415
+    }
416
+}
417
+
331 418
 extension GoogleClassroomClientError: LocalizedError {
332 419
     var errorDescription: String? {
333 420
         switch self {

+ 259 - 12
classroom_app/ViewController.swift

@@ -15,8 +15,9 @@ private enum SidebarPage: Int {
15 15
     case joinMeetings = 0
16 16
     case photo = 1
17 17
     case enrolled = 2
18
-    case video = 3
19
-    case settings = 4
18
+    case teaching = 3
19
+    case video = 4
20
+    case settings = 5
20 21
 }
21 22
 
22 23
 private enum ZoomJoinMode: Int {
@@ -289,6 +290,7 @@ final class ViewController: NSViewController {
289 290
     private var lastKnownPremiumAccess = false
290 291
     private var displayedScheduleTodos: [ClassroomTodoItem] = []
291 292
     private var enrolledCachedCourses: [ClassroomCourse] = []
293
+    private var teachingCachedCourses: [ClassroomCourse] = []
292 294
     private var appUsageSessionStartDate: Date?
293 295
     private var hasObservedAppLifecycleForUsage = false
294 296
     private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
@@ -360,9 +362,11 @@ final class ViewController: NSViewController {
360 362
     private weak var schedulePageCardsScrollView: NSScrollView?
361 363
     private weak var enrolledPageHeadingLabel: NSTextField?
362 364
     private weak var enrolledPageCardsStack: NSStackView?
363
-    private let enrolledSingleCardWidth: CGFloat = 320
365
+    private weak var teachingPageHeadingLabel: NSTextField?
366
+    private weak var teachingPageCardsStack: NSStackView?
364 367
     private var enrolledClassDetailsPopover: NSPopover?
365 368
     private var enrolledCourseByCardID: [String: ClassroomCourse] = [:]
369
+    private var teachingCourseByCardID: [String: ClassroomCourse] = [:]
366 370
 
367 371
     // MARK: - Calendar page (custom month UI)
368 372
     private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
@@ -1270,6 +1274,7 @@ private extension ViewController {
1270 1274
         pageCache[.joinMeetings] = nil
1271 1275
         pageCache[.photo] = nil
1272 1276
         pageCache[.enrolled] = nil
1277
+        pageCache[.teaching] = nil
1273 1278
         pageCache[.video] = nil
1274 1279
         pageCache[.settings] = nil
1275 1280
         showSidebarPage(selectedSidebarPage)
@@ -1518,6 +1523,8 @@ private extension ViewController {
1518 1523
             built = makeSchedulePageContent()
1519 1524
         case .enrolled:
1520 1525
             built = makeEnrolledPageContent()
1526
+        case .teaching:
1527
+            built = makeTeachingPageContent()
1521 1528
         case .video:
1522 1529
             built = makeCalendarPageContent()
1523 1530
         case .settings:
@@ -1878,6 +1885,8 @@ private extension ViewController {
1878 1885
             title = "Schedule"
1879 1886
         case .enrolled:
1880 1887
             title = "Enrolled"
1888
+        case .teaching:
1889
+            title = "Teaching"
1881 1890
         case .video:
1882 1891
             title = "Calendar"
1883 1892
         case .settings:
@@ -1921,7 +1930,7 @@ private extension ViewController {
1921 1930
     private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
1922 1931
         switch page {
1923 1932
         case .photo: return false
1924
-        case .joinMeetings, .enrolled, .video, .settings: return true
1933
+        case .joinMeetings, .enrolled, .teaching, .video, .settings: return true
1925 1934
         }
1926 1935
     }
1927 1936
 
@@ -1982,12 +1991,15 @@ private extension ViewController {
1982 1991
         menuStack.addArrangedSubview(joinRow)
1983 1992
         sidebarRowViews[.joinMeetings] = joinRow
1984 1993
         menuStack.addArrangedSubview(sidebarSectionTitle("Planning"))
1985
-        let photoRow = sidebarItem("Schedule", icon: "􀏂", page: .photo, systemSymbolName: "clock.badge.checkmark")
1994
+        let photoRow = sidebarItem("To-Do", icon: "􀏂", page: .photo, systemSymbolName: "clock.badge.checkmark")
1986 1995
         menuStack.addArrangedSubview(photoRow)
1987 1996
         sidebarRowViews[.photo] = photoRow
1988 1997
         let enrolledRow = sidebarItem("Enrolled", icon: "􀆄", page: .enrolled, systemSymbolName: "person.3.sequence.fill")
1989 1998
         menuStack.addArrangedSubview(enrolledRow)
1990 1999
         sidebarRowViews[.enrolled] = enrolledRow
2000
+        let teachingRow = sidebarItem("Teaching", icon: "􀅼", page: .teaching, systemSymbolName: "person.2.badge.gearshape.fill")
2001
+        menuStack.addArrangedSubview(teachingRow)
2002
+        sidebarRowViews[.teaching] = teachingRow
1991 2003
         let videoRow = sidebarItem("Calendar", icon: "􀎚", page: .video, systemSymbolName: "calendar")
1992 2004
         menuStack.addArrangedSubview(videoRow)
1993 2005
         sidebarRowViews[.video] = videoRow
@@ -2328,6 +2340,109 @@ private extension ViewController {
2328 2340
         return panel
2329 2341
     }
2330 2342
 
2343
+    func makeTeachingPageContent() -> NSView {
2344
+        let panel = NSView()
2345
+        panel.translatesAutoresizingMaskIntoConstraints = false
2346
+        panel.userInterfaceLayoutDirection = .leftToRight
2347
+
2348
+        let contentStack = NSStackView()
2349
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
2350
+        contentStack.userInterfaceLayoutDirection = .leftToRight
2351
+        contentStack.orientation = .vertical
2352
+        contentStack.spacing = 14
2353
+        contentStack.alignment = .width
2354
+        contentStack.distribution = .fill
2355
+
2356
+        let titleRow = NSStackView()
2357
+        titleRow.translatesAutoresizingMaskIntoConstraints = false
2358
+        titleRow.userInterfaceLayoutDirection = .leftToRight
2359
+        titleRow.orientation = .horizontal
2360
+        titleRow.alignment = .centerY
2361
+        titleRow.distribution = .fill
2362
+        titleRow.spacing = 10
2363
+
2364
+        let titleLabel = textLabel("Teaching Classes", font: typography.pageTitle, color: palette.textPrimary)
2365
+        titleLabel.alignment = .left
2366
+        titleLabel.maximumNumberOfLines = 1
2367
+        titleLabel.lineBreakMode = .byTruncatingTail
2368
+        titleLabel.setContentHuggingPriority(.required, for: .horizontal)
2369
+        titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
2370
+
2371
+        let spacer = NSView()
2372
+        spacer.translatesAutoresizingMaskIntoConstraints = false
2373
+        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
2374
+        spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2375
+
2376
+        let refreshButton = makeScheduleRefreshButton()
2377
+        refreshButton.target = self
2378
+        refreshButton.action = #selector(teachingPageRefreshPressed(_:))
2379
+
2380
+        titleRow.addArrangedSubview(titleLabel)
2381
+        titleRow.addArrangedSubview(spacer)
2382
+        titleRow.addArrangedSubview(refreshButton)
2383
+
2384
+        let heading = textLabel(teachingPageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
2385
+        heading.alignment = .left
2386
+        heading.maximumNumberOfLines = 2
2387
+        heading.lineBreakMode = .byWordWrapping
2388
+        teachingPageHeadingLabel = heading
2389
+
2390
+        let scroll = NSScrollView()
2391
+        scroll.translatesAutoresizingMaskIntoConstraints = false
2392
+        scroll.userInterfaceLayoutDirection = .leftToRight
2393
+        scroll.drawsBackground = false
2394
+        scroll.hasHorizontalScroller = false
2395
+        scroll.hasVerticalScroller = true
2396
+        scroll.autohidesScrollers = true
2397
+        scroll.borderType = .noBorder
2398
+        scroll.scrollerStyle = .overlay
2399
+        scroll.automaticallyAdjustsContentInsets = false
2400
+        let clip = TopAlignedClipView()
2401
+        clip.drawsBackground = false
2402
+        scroll.contentView = clip
2403
+
2404
+        let stack = NSStackView()
2405
+        stack.translatesAutoresizingMaskIntoConstraints = false
2406
+        stack.userInterfaceLayoutDirection = .leftToRight
2407
+        stack.orientation = .vertical
2408
+        stack.spacing = 14
2409
+        stack.alignment = .leading
2410
+        teachingPageCardsStack = stack
2411
+        scroll.documentView = stack
2412
+
2413
+        NSLayoutConstraint.activate([
2414
+            stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
2415
+            stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
2416
+            stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
2417
+            stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor)
2418
+        ])
2419
+
2420
+        contentStack.addArrangedSubview(titleRow)
2421
+        contentStack.setCustomSpacing(10, after: titleRow)
2422
+        contentStack.addArrangedSubview(heading)
2423
+        contentStack.setCustomSpacing(12, after: heading)
2424
+        contentStack.addArrangedSubview(scroll)
2425
+        panel.addSubview(contentStack)
2426
+
2427
+        NSLayoutConstraint.activate([
2428
+            contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28),
2429
+            contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28),
2430
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
2431
+            contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16),
2432
+            titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
2433
+            heading.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
2434
+            scroll.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
2435
+            scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420)
2436
+        ])
2437
+
2438
+        renderTeachingClassCards([])
2439
+        Task { [weak self] in
2440
+            await self?.loadTeachingClasses()
2441
+        }
2442
+
2443
+        return panel
2444
+    }
2445
+
2331 2446
     func makeCalendarPageContent() -> NSView {
2332 2447
         let panel = NSView()
2333 2448
         panel.translatesAutoresizingMaskIntoConstraints = false
@@ -6461,12 +6576,22 @@ private extension ViewController {
6461 6576
         googleOAuth.loadTokens() == nil ? "Connect Google to see enrolled classes" : "Loading classes…"
6462 6577
     }
6463 6578
 
6579
+    private func teachingPageInitialHeadingText() -> String {
6580
+        googleOAuth.loadTokens() == nil ? "Connect Google to see teaching classes" : "Loading classes…"
6581
+    }
6582
+
6464 6583
     @objc func enrolledPageRefreshPressed(_ sender: NSButton) {
6465 6584
         Task { [weak self] in
6466 6585
             await self?.loadEnrolledClasses()
6467 6586
         }
6468 6587
     }
6469 6588
 
6589
+    @objc func teachingPageRefreshPressed(_ sender: NSButton) {
6590
+        Task { [weak self] in
6591
+            await self?.loadTeachingClasses()
6592
+        }
6593
+    }
6594
+
6470 6595
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
6471 6596
         guard let selectedItem = sender.selectedItem,
6472 6597
               let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
@@ -6578,6 +6703,12 @@ private extension ViewController {
6578 6703
         return "\(courses.count) enrolled class\(courses.count == 1 ? "" : "es")"
6579 6704
     }
6580 6705
 
6706
+    private func teachingPageHeadingText(for courses: [ClassroomCourse]) -> String {
6707
+        if googleOAuth.loadTokens() == nil { return "Connect Google to see teaching classes" }
6708
+        if courses.isEmpty { return "No active teaching classes" }
6709
+        return "\(courses.count) teaching class\(courses.count == 1 ? "" : "es")"
6710
+    }
6711
+
6581 6712
     private func openURL(_ url: URL) {
6582 6713
         NSWorkspace.shared.open(url)
6583 6714
     }
@@ -6616,16 +6747,51 @@ private extension ViewController {
6616 6747
             return
6617 6748
         }
6618 6749
 
6619
-        let isSingleCard = (courses.count == 1)
6620 6750
         for course in courses {
6621 6751
             let card = enrolledClassCardButton(course: course)
6622 6752
             stack.addArrangedSubview(card)
6623
-            if isSingleCard {
6624
-                card.widthAnchor.constraint(equalToConstant: enrolledSingleCardWidth).isActive = true
6625
-                card.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor).isActive = true
6626
-            } else {
6627
-                card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
6628
-            }
6753
+            card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
6754
+        }
6755
+    }
6756
+
6757
+    private func renderTeachingClassCards(_ courses: [ClassroomCourse]) {
6758
+        guard let stack = teachingPageCardsStack else { return }
6759
+        teachingCourseByCardID.removeAll()
6760
+
6761
+        stack.arrangedSubviews.forEach { view in
6762
+            stack.removeArrangedSubview(view)
6763
+            view.removeFromSuperview()
6764
+        }
6765
+
6766
+        if courses.isEmpty {
6767
+            let empty = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
6768
+            empty.translatesAutoresizingMaskIntoConstraints = false
6769
+            empty.heightAnchor.constraint(equalToConstant: 128).isActive = true
6770
+            styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
6771
+
6772
+            let emptyLabel = textLabel(
6773
+                googleOAuth.loadTokens() == nil ? "Connect to load classes" : "No teaching classes found",
6774
+                font: typography.cardSubtitle,
6775
+                color: palette.textSecondary
6776
+            )
6777
+            emptyLabel.translatesAutoresizingMaskIntoConstraints = false
6778
+            emptyLabel.alignment = .left
6779
+            empty.addSubview(emptyLabel)
6780
+
6781
+            NSLayoutConstraint.activate([
6782
+                emptyLabel.leadingAnchor.constraint(equalTo: empty.leadingAnchor, constant: 18),
6783
+                emptyLabel.trailingAnchor.constraint(equalTo: empty.trailingAnchor, constant: -18),
6784
+                emptyLabel.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
6785
+            ])
6786
+            stack.addArrangedSubview(empty)
6787
+            empty.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
6788
+            return
6789
+        }
6790
+
6791
+        for course in courses {
6792
+            let card = teachingClassCardButton(course: course)
6793
+            stack.addArrangedSubview(card)
6794
+            card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
6629 6795
         }
6630 6796
     }
6631 6797
 
@@ -6664,6 +6830,12 @@ private extension ViewController {
6664 6830
         enrolledClassCardClicked(view: sender, course: course)
6665 6831
     }
6666 6832
 
6833
+    @objc private func teachingCardButtonPressed(_ sender: NSButton) {
6834
+        guard let courseID = sender.identifier?.rawValue,
6835
+              let course = teachingCourseByCardID[courseID] else { return }
6836
+        enrolledClassCardClicked(view: sender, course: course)
6837
+    }
6838
+
6667 6839
     private func enrolledClassCard(course: ClassroomCourse) -> NSView {
6668 6840
         let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
6669 6841
         card.translatesAutoresizingMaskIntoConstraints = false
@@ -6713,6 +6885,35 @@ private extension ViewController {
6713 6885
         return card
6714 6886
     }
6715 6887
 
6888
+    private func teachingClassCardButton(course: ClassroomCourse) -> NSView {
6889
+        let hit = HoverButton(title: "", target: self, action: #selector(teachingCardButtonPressed(_:)))
6890
+        hit.translatesAutoresizingMaskIntoConstraints = false
6891
+        hit.isBordered = false
6892
+        hit.bezelStyle = .regularSquare
6893
+        hit.wantsLayer = true
6894
+        hit.identifier = NSUserInterfaceItemIdentifier(course.id)
6895
+        teachingCourseByCardID[course.id] = course
6896
+        hit.heightAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true
6897
+
6898
+        let card = enrolledClassCard(course: course)
6899
+        hit.addSubview(card)
6900
+        NSLayoutConstraint.activate([
6901
+            card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
6902
+            card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
6903
+            card.topAnchor.constraint(equalTo: hit.topAnchor),
6904
+            card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
6905
+        ])
6906
+        hit.onHoverChanged = { [weak self, weak card] hovering in
6907
+            guard let self, let card else { return }
6908
+            let base = self.palette.sectionCard
6909
+            let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
6910
+            let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
6911
+            card.layer?.backgroundColor = (hovering ? hover : base).cgColor
6912
+        }
6913
+        hit.onHoverChanged?(false)
6914
+        return hit
6915
+    }
6916
+
6716 6917
     private func enrolledClassCardClicked(view: NSView, course: ClassroomCourse) {
6717 6918
         Task { [weak self, weak view] in
6718 6919
             guard let self, let view else { return }
@@ -6751,6 +6952,7 @@ private extension ViewController {
6751 6952
         }
6752 6953
     }
6753 6954
 
6955
+
6754 6956
     private func showEnrolledClassDetailsPopover(details: ClassroomClassDetails, defaultAuthorName: String, relativeTo anchor: NSView) {
6755 6957
         enrolledClassDetailsPopover?.performClose(nil)
6756 6958
         let popover = NSPopover()
@@ -7230,6 +7432,48 @@ private extension ViewController {
7230 7432
         }
7231 7433
     }
7232 7434
 
7435
+    private func loadTeachingClasses() async {
7436
+        do {
7437
+            if googleOAuth.loadTokens() == nil {
7438
+                await MainActor.run {
7439
+                    teachingCachedCourses = []
7440
+                    teachingPageHeadingLabel?.stringValue = "Connect Google to see teaching classes"
7441
+                    renderTeachingClassCards([])
7442
+                }
7443
+                return
7444
+            }
7445
+
7446
+            let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
7447
+            let courses = try await classroomClient.fetchTeachingCourses(accessToken: token)
7448
+
7449
+            await MainActor.run {
7450
+                teachingCachedCourses = courses
7451
+                teachingPageHeadingLabel?.stringValue = teachingPageHeadingText(for: courses)
7452
+                renderTeachingClassCards(courses)
7453
+            }
7454
+        } catch {
7455
+            await MainActor.run {
7456
+                if errorRequiresReconsentForClassroomScopes(error) {
7457
+                    _ = try? googleOAuth.signOut()
7458
+                    applyGoogleProfile(nil)
7459
+                    updateGoogleAuthButtonTitle()
7460
+                    teachingCachedCourses = []
7461
+                    teachingPageHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions"
7462
+                    renderTeachingClassCards([])
7463
+                    showSimpleAlert(
7464
+                        title: "Reconnect Google",
7465
+                        message: "We added Google Classroom permissions. Please connect your Google account again so Google can grant access to your classes."
7466
+                    )
7467
+                    return
7468
+                }
7469
+                teachingCachedCourses = []
7470
+                teachingPageHeadingLabel?.stringValue = "Couldn’t load teaching classes"
7471
+                renderTeachingClassCards([])
7472
+                showSimpleError("Couldn’t load teaching classes.", error: error)
7473
+            }
7474
+        }
7475
+    }
7476
+
7233 7477
     func showScheduleHelp() {
7234 7478
         let alert = NSAlert()
7235 7479
         alert.messageText = "Google Classroom to-do"
@@ -7249,6 +7493,7 @@ private extension ViewController {
7249 7493
                     self.pageCache[.joinMeetings] = nil
7250 7494
                     self.pageCache[.photo] = nil
7251 7495
                     self.pageCache[.enrolled] = nil
7496
+                    self.pageCache[.teaching] = nil
7252 7497
                     self.showSidebarPage(self.selectedSidebarPage)
7253 7498
                 }
7254 7499
                 await self.loadSchedule()
@@ -7278,6 +7523,7 @@ private extension ViewController {
7278 7523
                     self.pageCache[.joinMeetings] = nil
7279 7524
                     self.pageCache[.photo] = nil
7280 7525
                     self.pageCache[.enrolled] = nil
7526
+                    self.pageCache[.teaching] = nil
7281 7527
                     self.pageCache[.video] = nil
7282 7528
                     self.pageCache[.settings] = nil
7283 7529
                     self.showSidebarPage(self.selectedSidebarPage)
@@ -7330,6 +7576,7 @@ private extension ViewController {
7330 7576
             pageCache[.joinMeetings] = nil
7331 7577
             pageCache[.photo] = nil
7332 7578
             pageCache[.enrolled] = nil
7579
+            pageCache[.teaching] = nil
7333 7580
             pageCache[.video] = nil
7334 7581
             pageCache[.settings] = nil
7335 7582
             showSidebarPage(selectedSidebarPage)