Ver código fonte

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 semana atrás
pai
commit
b9d77b33cb

+ 88 - 1
classroom_app/Google/GoogleClassroomClient.swift

@@ -110,10 +110,60 @@ final class GoogleClassroomClient {
110
         return enrolled
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
     func fetchClassDetails(accessToken: String, courseId: String, courseName: String) async throws -> ClassroomClassDetails {
149
     func fetchClassDetails(accessToken: String, courseId: String, courseName: String) async throws -> ClassroomClassDetails {
114
         async let announcementsTask = listCourseAnnouncements(accessToken: accessToken, courseId: courseId, pageSize: 30)
150
         async let announcementsTask = listCourseAnnouncements(accessToken: accessToken, courseId: courseId, pageSize: 30)
115
         async let courseworkTask = listPublishedCourseWork(accessToken: accessToken, courseId: courseId, pageSize: 30)
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
         let mappedAnnouncements = announcements.map { item in
168
         let mappedAnnouncements = announcements.map { item in
119
             let text = item.text?.trimmingCharacters(in: .whitespacesAndNewlines)
169
             let text = item.text?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -180,6 +230,35 @@ final class GoogleClassroomClient {
180
         return decoded.courses ?? []
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
     private func listCourseTeachers(accessToken: String, courseId: String) async throws -> [Teacher] {
262
     private func listCourseTeachers(accessToken: String, courseId: String) async throws -> [Teacher] {
184
         var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/teachers")!
263
         var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/teachers")!
185
         components.queryItems = [
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
 extension GoogleClassroomClientError: LocalizedError {
418
 extension GoogleClassroomClientError: LocalizedError {
332
     var errorDescription: String? {
419
     var errorDescription: String? {
333
         switch self {
420
         switch self {

+ 259 - 12
classroom_app/ViewController.swift

@@ -15,8 +15,9 @@ private enum SidebarPage: Int {
15
     case joinMeetings = 0
15
     case joinMeetings = 0
16
     case photo = 1
16
     case photo = 1
17
     case enrolled = 2
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
 private enum ZoomJoinMode: Int {
23
 private enum ZoomJoinMode: Int {
@@ -289,6 +290,7 @@ final class ViewController: NSViewController {
289
     private var lastKnownPremiumAccess = false
290
     private var lastKnownPremiumAccess = false
290
     private var displayedScheduleTodos: [ClassroomTodoItem] = []
291
     private var displayedScheduleTodos: [ClassroomTodoItem] = []
291
     private var enrolledCachedCourses: [ClassroomCourse] = []
292
     private var enrolledCachedCourses: [ClassroomCourse] = []
293
+    private var teachingCachedCourses: [ClassroomCourse] = []
292
     private var appUsageSessionStartDate: Date?
294
     private var appUsageSessionStartDate: Date?
293
     private var hasObservedAppLifecycleForUsage = false
295
     private var hasObservedAppLifecycleForUsage = false
294
     private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
296
     private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
@@ -360,9 +362,11 @@ final class ViewController: NSViewController {
360
     private weak var schedulePageCardsScrollView: NSScrollView?
362
     private weak var schedulePageCardsScrollView: NSScrollView?
361
     private weak var enrolledPageHeadingLabel: NSTextField?
363
     private weak var enrolledPageHeadingLabel: NSTextField?
362
     private weak var enrolledPageCardsStack: NSStackView?
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
     private var enrolledClassDetailsPopover: NSPopover?
367
     private var enrolledClassDetailsPopover: NSPopover?
365
     private var enrolledCourseByCardID: [String: ClassroomCourse] = [:]
368
     private var enrolledCourseByCardID: [String: ClassroomCourse] = [:]
369
+    private var teachingCourseByCardID: [String: ClassroomCourse] = [:]
366
 
370
 
367
     // MARK: - Calendar page (custom month UI)
371
     // MARK: - Calendar page (custom month UI)
368
     private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
372
     private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
@@ -1270,6 +1274,7 @@ private extension ViewController {
1270
         pageCache[.joinMeetings] = nil
1274
         pageCache[.joinMeetings] = nil
1271
         pageCache[.photo] = nil
1275
         pageCache[.photo] = nil
1272
         pageCache[.enrolled] = nil
1276
         pageCache[.enrolled] = nil
1277
+        pageCache[.teaching] = nil
1273
         pageCache[.video] = nil
1278
         pageCache[.video] = nil
1274
         pageCache[.settings] = nil
1279
         pageCache[.settings] = nil
1275
         showSidebarPage(selectedSidebarPage)
1280
         showSidebarPage(selectedSidebarPage)
@@ -1518,6 +1523,8 @@ private extension ViewController {
1518
             built = makeSchedulePageContent()
1523
             built = makeSchedulePageContent()
1519
         case .enrolled:
1524
         case .enrolled:
1520
             built = makeEnrolledPageContent()
1525
             built = makeEnrolledPageContent()
1526
+        case .teaching:
1527
+            built = makeTeachingPageContent()
1521
         case .video:
1528
         case .video:
1522
             built = makeCalendarPageContent()
1529
             built = makeCalendarPageContent()
1523
         case .settings:
1530
         case .settings:
@@ -1878,6 +1885,8 @@ private extension ViewController {
1878
             title = "Schedule"
1885
             title = "Schedule"
1879
         case .enrolled:
1886
         case .enrolled:
1880
             title = "Enrolled"
1887
             title = "Enrolled"
1888
+        case .teaching:
1889
+            title = "Teaching"
1881
         case .video:
1890
         case .video:
1882
             title = "Calendar"
1891
             title = "Calendar"
1883
         case .settings:
1892
         case .settings:
@@ -1921,7 +1930,7 @@ private extension ViewController {
1921
     private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
1930
     private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
1922
         switch page {
1931
         switch page {
1923
         case .photo: return false
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
         menuStack.addArrangedSubview(joinRow)
1991
         menuStack.addArrangedSubview(joinRow)
1983
         sidebarRowViews[.joinMeetings] = joinRow
1992
         sidebarRowViews[.joinMeetings] = joinRow
1984
         menuStack.addArrangedSubview(sidebarSectionTitle("Planning"))
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
         menuStack.addArrangedSubview(photoRow)
1995
         menuStack.addArrangedSubview(photoRow)
1987
         sidebarRowViews[.photo] = photoRow
1996
         sidebarRowViews[.photo] = photoRow
1988
         let enrolledRow = sidebarItem("Enrolled", icon: "􀆄", page: .enrolled, systemSymbolName: "person.3.sequence.fill")
1997
         let enrolledRow = sidebarItem("Enrolled", icon: "􀆄", page: .enrolled, systemSymbolName: "person.3.sequence.fill")
1989
         menuStack.addArrangedSubview(enrolledRow)
1998
         menuStack.addArrangedSubview(enrolledRow)
1990
         sidebarRowViews[.enrolled] = enrolledRow
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
         let videoRow = sidebarItem("Calendar", icon: "􀎚", page: .video, systemSymbolName: "calendar")
2003
         let videoRow = sidebarItem("Calendar", icon: "􀎚", page: .video, systemSymbolName: "calendar")
1992
         menuStack.addArrangedSubview(videoRow)
2004
         menuStack.addArrangedSubview(videoRow)
1993
         sidebarRowViews[.video] = videoRow
2005
         sidebarRowViews[.video] = videoRow
@@ -2328,6 +2340,109 @@ private extension ViewController {
2328
         return panel
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
     func makeCalendarPageContent() -> NSView {
2446
     func makeCalendarPageContent() -> NSView {
2332
         let panel = NSView()
2447
         let panel = NSView()
2333
         panel.translatesAutoresizingMaskIntoConstraints = false
2448
         panel.translatesAutoresizingMaskIntoConstraints = false
@@ -6461,12 +6576,22 @@ private extension ViewController {
6461
         googleOAuth.loadTokens() == nil ? "Connect Google to see enrolled classes" : "Loading classes…"
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
     @objc func enrolledPageRefreshPressed(_ sender: NSButton) {
6583
     @objc func enrolledPageRefreshPressed(_ sender: NSButton) {
6465
         Task { [weak self] in
6584
         Task { [weak self] in
6466
             await self?.loadEnrolledClasses()
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
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
6595
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
6471
         guard let selectedItem = sender.selectedItem,
6596
         guard let selectedItem = sender.selectedItem,
6472
               let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
6597
               let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
@@ -6578,6 +6703,12 @@ private extension ViewController {
6578
         return "\(courses.count) enrolled class\(courses.count == 1 ? "" : "es")"
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
     private func openURL(_ url: URL) {
6712
     private func openURL(_ url: URL) {
6582
         NSWorkspace.shared.open(url)
6713
         NSWorkspace.shared.open(url)
6583
     }
6714
     }
@@ -6616,16 +6747,51 @@ private extension ViewController {
6616
             return
6747
             return
6617
         }
6748
         }
6618
 
6749
 
6619
-        let isSingleCard = (courses.count == 1)
6620
         for course in courses {
6750
         for course in courses {
6621
             let card = enrolledClassCardButton(course: course)
6751
             let card = enrolledClassCardButton(course: course)
6622
             stack.addArrangedSubview(card)
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
         enrolledClassCardClicked(view: sender, course: course)
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
     private func enrolledClassCard(course: ClassroomCourse) -> NSView {
6839
     private func enrolledClassCard(course: ClassroomCourse) -> NSView {
6668
         let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
6840
         let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
6669
         card.translatesAutoresizingMaskIntoConstraints = false
6841
         card.translatesAutoresizingMaskIntoConstraints = false
@@ -6713,6 +6885,35 @@ private extension ViewController {
6713
         return card
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
     private func enrolledClassCardClicked(view: NSView, course: ClassroomCourse) {
6917
     private func enrolledClassCardClicked(view: NSView, course: ClassroomCourse) {
6717
         Task { [weak self, weak view] in
6918
         Task { [weak self, weak view] in
6718
             guard let self, let view else { return }
6919
             guard let self, let view else { return }
@@ -6751,6 +6952,7 @@ private extension ViewController {
6751
         }
6952
         }
6752
     }
6953
     }
6753
 
6954
 
6955
+
6754
     private func showEnrolledClassDetailsPopover(details: ClassroomClassDetails, defaultAuthorName: String, relativeTo anchor: NSView) {
6956
     private func showEnrolledClassDetailsPopover(details: ClassroomClassDetails, defaultAuthorName: String, relativeTo anchor: NSView) {
6755
         enrolledClassDetailsPopover?.performClose(nil)
6957
         enrolledClassDetailsPopover?.performClose(nil)
6756
         let popover = NSPopover()
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
     func showScheduleHelp() {
7477
     func showScheduleHelp() {
7234
         let alert = NSAlert()
7478
         let alert = NSAlert()
7235
         alert.messageText = "Google Classroom to-do"
7479
         alert.messageText = "Google Classroom to-do"
@@ -7249,6 +7493,7 @@ private extension ViewController {
7249
                     self.pageCache[.joinMeetings] = nil
7493
                     self.pageCache[.joinMeetings] = nil
7250
                     self.pageCache[.photo] = nil
7494
                     self.pageCache[.photo] = nil
7251
                     self.pageCache[.enrolled] = nil
7495
                     self.pageCache[.enrolled] = nil
7496
+                    self.pageCache[.teaching] = nil
7252
                     self.showSidebarPage(self.selectedSidebarPage)
7497
                     self.showSidebarPage(self.selectedSidebarPage)
7253
                 }
7498
                 }
7254
                 await self.loadSchedule()
7499
                 await self.loadSchedule()
@@ -7278,6 +7523,7 @@ private extension ViewController {
7278
                     self.pageCache[.joinMeetings] = nil
7523
                     self.pageCache[.joinMeetings] = nil
7279
                     self.pageCache[.photo] = nil
7524
                     self.pageCache[.photo] = nil
7280
                     self.pageCache[.enrolled] = nil
7525
                     self.pageCache[.enrolled] = nil
7526
+                    self.pageCache[.teaching] = nil
7281
                     self.pageCache[.video] = nil
7527
                     self.pageCache[.video] = nil
7282
                     self.pageCache[.settings] = nil
7528
                     self.pageCache[.settings] = nil
7283
                     self.showSidebarPage(self.selectedSidebarPage)
7529
                     self.showSidebarPage(self.selectedSidebarPage)
@@ -7330,6 +7576,7 @@ private extension ViewController {
7330
             pageCache[.joinMeetings] = nil
7576
             pageCache[.joinMeetings] = nil
7331
             pageCache[.photo] = nil
7577
             pageCache[.photo] = nil
7332
             pageCache[.enrolled] = nil
7578
             pageCache[.enrolled] = nil
7579
+            pageCache[.teaching] = nil
7333
             pageCache[.video] = nil
7580
             pageCache[.video] = nil
7334
             pageCache[.settings] = nil
7581
             pageCache[.settings] = nil
7335
             showSidebarPage(selectedSidebarPage)
7582
             showSidebarPage(selectedSidebarPage)