ソースを参照

Add Classroom-style enrolled class stream popup.

Implement clickable enrolled class cards with a polished details popover that shows announcements and assignments, supports open-file actions with auth-aware fallback behavior, and handles scope reconsent for announcements access.

Made-with: Cursor
huzaifahayat12 1 週間 前
コミット
97613ec5a8

+ 1 - 0
classroom_app/Auth/GoogleOAuthService.swift

@@ -48,6 +48,7 @@ final class GoogleOAuthService: NSObject {
48 48
         // Classroom To-do (assignments/quizzes)
49 49
         "https://www.googleapis.com/auth/classroom.courses.readonly",
50 50
         "https://www.googleapis.com/auth/classroom.coursework.me.readonly",
51
+        "https://www.googleapis.com/auth/classroom.announcements.readonly",
51 52
         "https://www.googleapis.com/auth/classroom.rosters.readonly"
52 53
     ]
53 54
 

+ 198 - 0
classroom_app/Google/GoogleClassroomClient.swift

@@ -110,6 +110,45 @@ final class GoogleClassroomClient {
110 110
         return enrolled
111 111
     }
112 112
 
113
+    func fetchClassDetails(accessToken: String, courseId: String, courseName: String) async throws -> ClassroomClassDetails {
114
+        async let announcementsTask = listCourseAnnouncements(accessToken: accessToken, courseId: courseId, pageSize: 30)
115
+        async let courseworkTask = listPublishedCourseWork(accessToken: accessToken, courseId: courseId, pageSize: 30)
116
+        let (announcements, coursework) = try await (announcementsTask, courseworkTask)
117
+
118
+        let mappedAnnouncements = announcements.map { item in
119
+            let text = item.text?.trimmingCharacters(in: .whitespacesAndNewlines)
120
+            return ClassroomAnnouncement(
121
+                id: item.id ?? UUID().uuidString,
122
+                text: (text?.isEmpty == false) ? text! : "Announcement",
123
+                postedAt: item.createdDate,
124
+                alternateLink: item.alternateLink.flatMap(URL.init(string:)),
125
+                attachments: mapMaterialsToAttachments(item.materials)
126
+            )
127
+        }.sorted { ($0.postedAt ?? .distantPast) > ($1.postedAt ?? .distantPast) }
128
+
129
+        let mappedCourseWork = coursework.map { work in
130
+            let title = work.title?.trimmingCharacters(in: .whitespacesAndNewlines)
131
+            let workType = ClassroomTodoWorkType(rawValue: work.workType ?? "") ?? .unspecified
132
+            return ClassroomCourseWork(
133
+                id: work.id ?? UUID().uuidString,
134
+                title: (title?.isEmpty == false) ? title! : "Untitled",
135
+                subtitle: workType.displayName,
136
+                dueDate: work.resolvedDueDate,
137
+                alternateLink: work.alternateLink.flatMap(URL.init(string:)),
138
+                workType: workType,
139
+                attachments: mapMaterialsToAttachments(work.materials)
140
+            )
141
+        }.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
142
+
143
+        return ClassroomClassDetails(
144
+            courseId: courseId,
145
+            courseName: courseName,
146
+            courseLink: URL(string: "https://classroom.google.com/c/\(courseId)"),
147
+            announcements: mappedAnnouncements,
148
+            coursework: mappedCourseWork
149
+        )
150
+    }
151
+
113 152
     // MARK: - Courses
114 153
 
115 154
     private func listActiveCourses(accessToken: String, pageSize: Int) async throws -> [Course] {
@@ -168,6 +207,35 @@ final class GoogleClassroomClient {
168 207
         return decoded.teachers ?? []
169 208
     }
170 209
 
210
+    private func listCourseAnnouncements(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseAnnouncement] {
211
+        var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/announcements")!
212
+        components.queryItems = [
213
+            URLQueryItem(name: "announcementStates", value: "PUBLISHED"),
214
+            URLQueryItem(name: "orderBy", value: "updateTime desc"),
215
+            URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
216
+        ]
217
+
218
+        var request = URLRequest(url: components.url!)
219
+        request.httpMethod = "GET"
220
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
221
+
222
+        let (data, response) = try await session.data(for: request)
223
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
224
+        guard (200..<300).contains(http.statusCode) else {
225
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
226
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
227
+        }
228
+
229
+        let decoded: CourseAnnouncementList
230
+        do {
231
+            decoded = try JSONDecoder().decode(CourseAnnouncementList.self, from: data)
232
+        } catch {
233
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
234
+            throw GoogleClassroomClientError.decodeFailed(raw)
235
+        }
236
+        return decoded.announcements ?? []
237
+    }
238
+
171 239
     // MARK: - CourseWork
172 240
 
173 241
     private func listPublishedCourseWork(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseWork] {
@@ -198,6 +266,66 @@ final class GoogleClassroomClient {
198 266
         }
199 267
         return decoded.courseWork ?? []
200 268
     }
269
+
270
+    private func mapMaterialsToAttachments(_ materials: [Material]?) -> [ClassroomAttachment] {
271
+        guard let materials else { return [] }
272
+        var attachments: [ClassroomAttachment] = []
273
+        attachments.reserveCapacity(materials.count)
274
+
275
+        for (index, material) in materials.enumerated() {
276
+            if let drive = material.driveFile?.driveFile,
277
+               let linkString = drive.alternateLink,
278
+               let url = URL(string: linkString) {
279
+                attachments.append(
280
+                    ClassroomAttachment(
281
+                        id: drive.id ?? "drive-\(index)",
282
+                        title: drive.title?.nonEmptyOr("Drive file") ?? "Drive file",
283
+                        url: url,
284
+                        mimeType: drive.mimeType,
285
+                        sourceType: "driveFile"
286
+                    )
287
+                )
288
+            } else if let link = material.link,
289
+                      let linkString = link.url,
290
+                      let url = URL(string: linkString) {
291
+                attachments.append(
292
+                    ClassroomAttachment(
293
+                        id: "link-\(index)",
294
+                        title: link.title?.nonEmptyOr("Link") ?? "Link",
295
+                        url: url,
296
+                        mimeType: nil,
297
+                        sourceType: "link"
298
+                    )
299
+                )
300
+            } else if let form = material.form,
301
+                      let linkString = form.formUrl,
302
+                      let url = URL(string: linkString) {
303
+                attachments.append(
304
+                    ClassroomAttachment(
305
+                        id: "form-\(index)",
306
+                        title: form.title?.nonEmptyOr("Google Form") ?? "Google Form",
307
+                        url: url,
308
+                        mimeType: nil,
309
+                        sourceType: "form"
310
+                    )
311
+                )
312
+            } else if let yt = material.youtubeVideo,
313
+                      let linkString = yt.alternateLink,
314
+                      let url = URL(string: linkString) {
315
+                attachments.append(
316
+                    ClassroomAttachment(
317
+                        id: "youtube-\(index)",
318
+                        title: yt.title?.nonEmptyOr("YouTube") ?? "YouTube",
319
+                        url: url,
320
+                        mimeType: nil,
321
+                        sourceType: "youtubeVideo"
322
+                    )
323
+                )
324
+            }
325
+        }
326
+
327
+        return attachments
328
+    }
201 329
 }
202 330
 
203 331
 extension GoogleClassroomClientError: LocalizedError {
@@ -241,6 +369,7 @@ private struct CourseWork: Decodable {
241 369
     let workType: String?
242 370
     let dueDate: DateParts?
243 371
     let dueTime: TimeOfDay?
372
+    let materials: [Material]?
244 373
 
245 374
     var resolvedDueDate: Date? {
246 375
         guard let dueDate else { return nil }
@@ -263,6 +392,24 @@ private struct CourseWork: Decodable {
263 392
     }
264 393
 }
265 394
 
395
+private struct CourseAnnouncementList: Decodable {
396
+    let announcements: [CourseAnnouncement]?
397
+    let nextPageToken: String?
398
+}
399
+
400
+private struct CourseAnnouncement: Decodable {
401
+    let id: String?
402
+    let text: String?
403
+    let alternateLink: String?
404
+    let creationTime: String?
405
+    let materials: [Material]?
406
+
407
+    var createdDate: Date? {
408
+        guard let creationTime else { return nil }
409
+        return Date.parseRFC3339(creationTime)
410
+    }
411
+}
412
+
266 413
 private struct DateParts: Decodable {
267 414
     let year: Int?
268 415
     let month: Int?
@@ -276,6 +423,39 @@ private struct TimeOfDay: Decodable {
276 423
     let nanos: Int?
277 424
 }
278 425
 
426
+private struct Material: Decodable {
427
+    let driveFile: SharedDriveFileContainer?
428
+    let link: SharedLink?
429
+    let form: SharedForm?
430
+    let youtubeVideo: SharedYoutubeVideo?
431
+}
432
+
433
+private struct SharedDriveFileContainer: Decodable {
434
+    let driveFile: SharedDriveFile?
435
+}
436
+
437
+private struct SharedDriveFile: Decodable {
438
+    let id: String?
439
+    let title: String?
440
+    let alternateLink: String?
441
+    let mimeType: String?
442
+}
443
+
444
+private struct SharedLink: Decodable {
445
+    let url: String?
446
+    let title: String?
447
+}
448
+
449
+private struct SharedForm: Decodable {
450
+    let formUrl: String?
451
+    let title: String?
452
+}
453
+
454
+private struct SharedYoutubeVideo: Decodable {
455
+    let alternateLink: String?
456
+    let title: String?
457
+}
458
+
279 459
 private struct TeachersList: Decodable {
280 460
     let teachers: [Teacher]?
281 461
     let nextPageToken: String?
@@ -293,3 +473,21 @@ private struct UserProfile: Decodable {
293 473
 private struct UserName: Decodable {
294 474
     let fullName: String?
295 475
 }
476
+
477
+private extension String {
478
+    func nonEmptyOr(_ fallback: String) -> String {
479
+        let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
480
+        return trimmed.isEmpty ? fallback : trimmed
481
+    }
482
+}
483
+
484
+private extension Date {
485
+    static func parseRFC3339(_ text: String) -> Date? {
486
+        let formatterWithFractional = ISO8601DateFormatter()
487
+        formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
488
+        if let value = formatterWithFractional.date(from: text) { return value }
489
+        let formatter = ISO8601DateFormatter()
490
+        formatter.formatOptions = [.withInternetDateTime]
491
+        return formatter.date(from: text)
492
+    }
493
+}

+ 35 - 0
classroom_app/Models/ClassroomClassFeedItem.swift

@@ -0,0 +1,35 @@
1
+import Foundation
2
+
3
+struct ClassroomClassDetails: Equatable {
4
+    let courseId: String
5
+    let courseName: String
6
+    let courseLink: URL?
7
+    let announcements: [ClassroomAnnouncement]
8
+    let coursework: [ClassroomCourseWork]
9
+}
10
+
11
+struct ClassroomAnnouncement: Identifiable, Equatable {
12
+    let id: String
13
+    let text: String
14
+    let postedAt: Date?
15
+    let alternateLink: URL?
16
+    let attachments: [ClassroomAttachment]
17
+}
18
+
19
+struct ClassroomCourseWork: Identifiable, Equatable {
20
+    let id: String
21
+    let title: String
22
+    let subtitle: String
23
+    let dueDate: Date?
24
+    let alternateLink: URL?
25
+    let workType: ClassroomTodoWorkType
26
+    let attachments: [ClassroomAttachment]
27
+}
28
+
29
+struct ClassroomAttachment: Identifiable, Equatable {
30
+    let id: String
31
+    let title: String
32
+    let url: URL
33
+    let mimeType: String?
34
+    let sourceType: String
35
+}

+ 586 - 1
classroom_app/ViewController.swift

@@ -361,6 +361,8 @@ final class ViewController: NSViewController {
361 361
     private weak var enrolledPageHeadingLabel: NSTextField?
362 362
     private weak var enrolledPageCardsStack: NSStackView?
363 363
     private let enrolledSingleCardWidth: CGFloat = 320
364
+    private var enrolledClassDetailsPopover: NSPopover?
365
+    private var enrolledCourseByCardID: [String: ClassroomCourse] = [:]
364 366
 
365 367
     // MARK: - Calendar page (custom month UI)
366 368
     private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
@@ -5405,6 +5407,417 @@ private extension ViewController {
5405 5407
     }
5406 5408
 }
5407 5409
 
5410
+private final class EnrolledClassDetailsViewController: NSViewController {
5411
+    private let details: ClassroomClassDetails
5412
+    private let palette: Palette
5413
+    private let typography: Typography
5414
+    private let defaultAuthorName: String
5415
+    private let defaultAuthorAvatar: NSImage?
5416
+    private let onOpenClass: () -> Void
5417
+    private let onOpenURL: (URL) -> Void
5418
+    private let onDownloadAttachment: (ClassroomAttachment) -> Void
5419
+    private var attachmentByButton = [ObjectIdentifier: ClassroomAttachment]()
5420
+
5421
+    init(details: ClassroomClassDetails,
5422
+         palette: Palette,
5423
+         typography: Typography,
5424
+         defaultAuthorName: String,
5425
+         defaultAuthorAvatar: NSImage?,
5426
+         onOpenClass: @escaping () -> Void,
5427
+         onOpenURL: @escaping (URL) -> Void,
5428
+         onDownloadAttachment: @escaping (ClassroomAttachment) -> Void) {
5429
+        self.details = details
5430
+        self.palette = palette
5431
+        self.typography = typography
5432
+        self.defaultAuthorName = defaultAuthorName
5433
+        self.defaultAuthorAvatar = defaultAuthorAvatar
5434
+        self.onOpenClass = onOpenClass
5435
+        self.onOpenURL = onOpenURL
5436
+        self.onDownloadAttachment = onDownloadAttachment
5437
+        super.init(nibName: nil, bundle: nil)
5438
+    }
5439
+
5440
+    required init?(coder: NSCoder) { nil }
5441
+
5442
+    override func loadView() {
5443
+        let root = NSView()
5444
+        root.translatesAutoresizingMaskIntoConstraints = false
5445
+        root.wantsLayer = true
5446
+        root.layer?.backgroundColor = palette.sectionCard.cgColor
5447
+        root.layer?.cornerRadius = 12
5448
+        root.layer?.borderWidth = 1
5449
+        root.layer?.borderColor = palette.inputBorder.cgColor
5450
+
5451
+        let contentScroll = NSScrollView()
5452
+        contentScroll.translatesAutoresizingMaskIntoConstraints = false
5453
+        contentScroll.drawsBackground = false
5454
+        contentScroll.hasVerticalScroller = true
5455
+        contentScroll.hasHorizontalScroller = false
5456
+        contentScroll.autohidesScrollers = true
5457
+        contentScroll.borderType = .noBorder
5458
+        contentScroll.scrollerStyle = .overlay
5459
+        let clip = TopAlignedClipView()
5460
+        clip.drawsBackground = false
5461
+        contentScroll.contentView = clip
5462
+        root.addSubview(contentScroll)
5463
+
5464
+        let stack = NSStackView()
5465
+        stack.translatesAutoresizingMaskIntoConstraints = false
5466
+        stack.orientation = .vertical
5467
+        stack.alignment = .width
5468
+        stack.spacing = 16
5469
+        contentScroll.documentView = stack
5470
+
5471
+        let pageHeader = makePageHeader()
5472
+        stack.addArrangedSubview(pageHeader)
5473
+
5474
+        if details.announcements.isEmpty, details.coursework.isEmpty {
5475
+            let empty = makeEmptyState()
5476
+            stack.addArrangedSubview(empty)
5477
+            empty.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
5478
+        } else {
5479
+            for item in details.announcements {
5480
+                let card = makeAnnouncementCard(item)
5481
+                stack.addArrangedSubview(card)
5482
+                card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
5483
+            }
5484
+            for item in details.coursework {
5485
+                let card = makeCourseworkCard(item)
5486
+                stack.addArrangedSubview(card)
5487
+                card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
5488
+            }
5489
+        }
5490
+
5491
+        NSLayoutConstraint.activate([
5492
+            root.widthAnchor.constraint(equalToConstant: 520),
5493
+            root.heightAnchor.constraint(equalToConstant: 560),
5494
+            contentScroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12),
5495
+            contentScroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12),
5496
+            contentScroll.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
5497
+            contentScroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12),
5498
+            stack.leadingAnchor.constraint(equalTo: contentScroll.contentView.leadingAnchor),
5499
+            stack.trailingAnchor.constraint(equalTo: contentScroll.contentView.trailingAnchor),
5500
+            stack.topAnchor.constraint(equalTo: contentScroll.contentView.topAnchor),
5501
+            stack.widthAnchor.constraint(equalTo: contentScroll.contentView.widthAnchor),
5502
+            pageHeader.widthAnchor.constraint(equalTo: stack.widthAnchor)
5503
+        ])
5504
+
5505
+        view = root
5506
+    }
5507
+
5508
+    @objc private func openClassPressed(_ sender: NSButton) {
5509
+        onOpenClass()
5510
+    }
5511
+
5512
+    @objc private func openLinkPressed(_ sender: NSButton) {
5513
+        guard let raw = sender.identifier?.rawValue, let url = URL(string: raw) else { return }
5514
+        onOpenURL(url)
5515
+    }
5516
+
5517
+    @objc private func downloadPressed(_ sender: NSButton) {
5518
+        guard let attachment = attachmentByButton[ObjectIdentifier(sender)] else { return }
5519
+        onDownloadAttachment(attachment)
5520
+    }
5521
+
5522
+    private func makePageHeader() -> NSView {
5523
+        let title = NSTextField(labelWithString: details.courseName)
5524
+        title.font = NSFont.systemFont(ofSize: 18, weight: .bold)
5525
+        title.textColor = palette.textPrimary
5526
+        title.maximumNumberOfLines = 2
5527
+        title.lineBreakMode = .byWordWrapping
5528
+
5529
+        let subtitle = NSTextField(labelWithString: "Class stream")
5530
+        subtitle.font = NSFont.systemFont(ofSize: 12, weight: .medium)
5531
+        subtitle.textColor = palette.textMuted
5532
+
5533
+        let openClassButton = NSButton(title: "Open in Classroom", target: self, action: #selector(openClassPressed(_:)))
5534
+        openClassButton.bezelStyle = .rounded
5535
+        openClassButton.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
5536
+
5537
+        let left = NSStackView(views: [title, subtitle])
5538
+        left.orientation = .vertical
5539
+        left.alignment = .leading
5540
+        left.spacing = 4
5541
+
5542
+        let row = NSStackView(views: [left, NSView(), openClassButton])
5543
+        row.translatesAutoresizingMaskIntoConstraints = false
5544
+        row.orientation = .horizontal
5545
+        row.alignment = .centerY
5546
+
5547
+        let card = makeStreamCardShell()
5548
+        card.addSubview(row)
5549
+        NSLayoutConstraint.activate([
5550
+            row.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
5551
+            row.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
5552
+            row.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
5553
+            row.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
5554
+        ])
5555
+        return card
5556
+    }
5557
+
5558
+    private func makeEmptyState() -> NSView {
5559
+        let card = makeStreamCardShell()
5560
+        let stack = NSStackView()
5561
+        stack.translatesAutoresizingMaskIntoConstraints = false
5562
+        stack.orientation = .vertical
5563
+        stack.alignment = .centerX
5564
+        stack.spacing = 8
5565
+
5566
+        let title = NSTextField(labelWithString: "No stream items yet")
5567
+        title.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
5568
+        title.textColor = palette.textPrimary
5569
+
5570
+        let subtitle = NSTextField(labelWithString: "Announcements and coursework will appear here.")
5571
+        subtitle.font = typography.cardSubtitle
5572
+        subtitle.textColor = palette.textSecondary
5573
+
5574
+        stack.addArrangedSubview(title)
5575
+        stack.addArrangedSubview(subtitle)
5576
+        card.addSubview(stack)
5577
+        NSLayoutConstraint.activate([
5578
+            stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
5579
+            stack.centerYAnchor.constraint(equalTo: card.centerYAnchor),
5580
+            stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 16),
5581
+            stack.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -16),
5582
+            card.heightAnchor.constraint(equalToConstant: 120)
5583
+        ])
5584
+        return card
5585
+    }
5586
+
5587
+    private func makeMutedLabel(_ text: String) -> NSTextField {
5588
+        let label = NSTextField(labelWithString: text)
5589
+        label.font = typography.cardSubtitle
5590
+        label.textColor = palette.textSecondary
5591
+        return label
5592
+    }
5593
+
5594
+    private func makeAnnouncementCard(_ item: ClassroomAnnouncement) -> NSView {
5595
+        let container = NSStackView()
5596
+        container.translatesAutoresizingMaskIntoConstraints = false
5597
+        container.orientation = .vertical
5598
+        container.alignment = .leading
5599
+        container.spacing = 10
5600
+        container.addArrangedSubview(makeStreamMetaRow(authorName: defaultAuthorName, date: item.postedAt))
5601
+        container.addArrangedSubview(makeBodyLabel(item.text))
5602
+        if !item.attachments.isEmpty {
5603
+            container.addArrangedSubview(makeAttachmentPreview(item.attachments[0]))
5604
+        }
5605
+        if let link = item.alternateLink {
5606
+            let open = makeInlineActionButton(title: "Open announcement", url: link)
5607
+            container.addArrangedSubview(open)
5608
+        }
5609
+        container.addArrangedSubview(makeCommentRow())
5610
+        return wrapInStreamCard(container)
5611
+    }
5612
+
5613
+    private func makeCourseworkCard(_ item: ClassroomCourseWork) -> NSView {
5614
+        let container = NSStackView()
5615
+        container.translatesAutoresizingMaskIntoConstraints = false
5616
+        container.orientation = .vertical
5617
+        container.alignment = .leading
5618
+        container.spacing = 10
5619
+
5620
+        container.addArrangedSubview(makeStreamMetaRow(authorName: defaultAuthorName, date: item.dueDate))
5621
+        let headline = "\(defaultAuthorName) posted a new \(item.subtitle.lowercased()): \(item.title)"
5622
+        container.addArrangedSubview(makeBodyLabel(headline))
5623
+        if !item.attachments.isEmpty {
5624
+            container.addArrangedSubview(makeAttachmentPreview(item.attachments[0]))
5625
+        }
5626
+        if let link = item.alternateLink {
5627
+            let open = makeInlineActionButton(title: "Open item", url: link)
5628
+            container.addArrangedSubview(open)
5629
+        }
5630
+        return wrapInStreamCard(container)
5631
+    }
5632
+
5633
+    private func makeAttachmentsList(_ attachments: [ClassroomAttachment]) -> NSView {
5634
+        let stack = NSStackView()
5635
+        stack.orientation = .vertical
5636
+        stack.alignment = .leading
5637
+        stack.spacing = 2
5638
+
5639
+        let header = NSTextField(labelWithString: "Attachments")
5640
+        header.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
5641
+        header.textColor = palette.textMuted
5642
+        stack.addArrangedSubview(header)
5643
+
5644
+        for attachment in attachments {
5645
+            let button = NSButton(title: "Open file: \(attachment.title)", target: self, action: #selector(downloadPressed(_:)))
5646
+            button.bezelStyle = .inline
5647
+            button.font = NSFont.systemFont(ofSize: 12, weight: .medium)
5648
+            attachmentByButton[ObjectIdentifier(button)] = attachment
5649
+            stack.addArrangedSubview(button)
5650
+        }
5651
+        return stack
5652
+    }
5653
+
5654
+    private func makeStreamMetaRow(authorName: String, date: Date?) -> NSView {
5655
+        let avatar = NSImageView()
5656
+        avatar.translatesAutoresizingMaskIntoConstraints = false
5657
+        if let defaultAuthorAvatar {
5658
+            avatar.image = circularNSImage(defaultAuthorAvatar, diameter: 34)
5659
+            avatar.contentTintColor = nil
5660
+        } else {
5661
+            avatar.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Author")
5662
+            avatar.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 28, weight: .regular)
5663
+            avatar.contentTintColor = palette.textSecondary
5664
+        }
5665
+        avatar.widthAnchor.constraint(equalToConstant: 34).isActive = true
5666
+        avatar.heightAnchor.constraint(equalToConstant: 34).isActive = true
5667
+
5668
+        let name = NSTextField(labelWithString: authorName)
5669
+        name.font = NSFont.systemFont(ofSize: 14, weight: .bold)
5670
+        name.textColor = palette.textPrimary
5671
+
5672
+        let time = makeMutedLabel(timeText(date))
5673
+        time.font = NSFont.systemFont(ofSize: 12, weight: .medium)
5674
+
5675
+        let nameTime = NSStackView(views: [name, time])
5676
+        nameTime.orientation = .vertical
5677
+        nameTime.alignment = .leading
5678
+        nameTime.spacing = 2
5679
+
5680
+        let menu = NSTextField(labelWithString: "⋮")
5681
+        menu.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
5682
+        menu.textColor = palette.textMuted
5683
+
5684
+        let row = NSStackView(views: [avatar, nameTime, NSView(), menu])
5685
+        row.orientation = .horizontal
5686
+        row.alignment = .top
5687
+        row.distribution = .fill
5688
+        return row
5689
+    }
5690
+
5691
+    private func makeBodyLabel(_ text: String) -> NSView {
5692
+        let body = NSTextField(wrappingLabelWithString: text)
5693
+        body.maximumNumberOfLines = 0
5694
+        body.font = NSFont.systemFont(ofSize: 15, weight: .regular)
5695
+        body.textColor = palette.textPrimary
5696
+        return body
5697
+    }
5698
+
5699
+    private func makeCommentRow() -> NSView {
5700
+        let divider = NSBox()
5701
+        divider.boxType = .separator
5702
+        divider.translatesAutoresizingMaskIntoConstraints = false
5703
+
5704
+        let comment = NSTextField(labelWithString: "Add comment")
5705
+        comment.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
5706
+        comment.textColor = palette.primaryBlue
5707
+
5708
+        let icon = NSImageView()
5709
+        icon.translatesAutoresizingMaskIntoConstraints = false
5710
+        icon.image = NSImage(systemSymbolName: "bubble.left.and.bubble.right", accessibilityDescription: nil)
5711
+        icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
5712
+        icon.contentTintColor = palette.primaryBlue
5713
+        icon.widthAnchor.constraint(equalToConstant: 15).isActive = true
5714
+        icon.heightAnchor.constraint(equalToConstant: 15).isActive = true
5715
+
5716
+        let row = NSStackView(views: [icon, comment])
5717
+        row.orientation = .horizontal
5718
+        row.alignment = .centerY
5719
+        row.spacing = 8
5720
+
5721
+        let container = NSStackView(views: [divider, row])
5722
+        container.orientation = .vertical
5723
+        container.alignment = .width
5724
+        container.spacing = 10
5725
+        return container
5726
+    }
5727
+
5728
+    private func makeAttachmentPreview(_ attachment: ClassroomAttachment) -> NSView {
5729
+        let preview = NSView()
5730
+        preview.translatesAutoresizingMaskIntoConstraints = false
5731
+        preview.wantsLayer = true
5732
+        preview.layer?.cornerRadius = 12
5733
+        preview.layer?.backgroundColor = palette.sectionCard.cgColor
5734
+        preview.layer?.borderWidth = 1
5735
+        preview.layer?.borderColor = palette.inputBorder.cgColor
5736
+
5737
+        let titleButton = NSButton(title: attachment.title, target: self, action: #selector(downloadPressed(_:)))
5738
+        titleButton.translatesAutoresizingMaskIntoConstraints = false
5739
+        titleButton.bezelStyle = .inline
5740
+        titleButton.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
5741
+        titleButton.contentTintColor = palette.primaryBlue
5742
+        attachmentByButton[ObjectIdentifier(titleButton)] = attachment
5743
+
5744
+        let type = makeMutedLabel(attachment.mimeType ?? attachment.sourceType.capitalized)
5745
+        type.translatesAutoresizingMaskIntoConstraints = false
5746
+
5747
+        let textCol = NSStackView(views: [titleButton, type])
5748
+        textCol.translatesAutoresizingMaskIntoConstraints = false
5749
+        textCol.orientation = .vertical
5750
+        textCol.alignment = .leading
5751
+        textCol.spacing = 2
5752
+
5753
+        let thumb = NSImageView()
5754
+        thumb.translatesAutoresizingMaskIntoConstraints = false
5755
+        thumb.image = NSImage(systemSymbolName: "doc.richtext", accessibilityDescription: nil)
5756
+        thumb.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .medium)
5757
+        thumb.contentTintColor = palette.textMuted
5758
+        thumb.wantsLayer = true
5759
+        thumb.layer?.backgroundColor = palette.inputBackground.cgColor
5760
+        thumb.layer?.cornerRadius = 8
5761
+        thumb.widthAnchor.constraint(equalToConstant: 88).isActive = true
5762
+
5763
+        preview.addSubview(textCol)
5764
+        preview.addSubview(thumb)
5765
+        NSLayoutConstraint.activate([
5766
+            preview.heightAnchor.constraint(equalToConstant: 90),
5767
+            textCol.leadingAnchor.constraint(equalTo: preview.leadingAnchor, constant: 12),
5768
+            textCol.trailingAnchor.constraint(equalTo: thumb.leadingAnchor, constant: -10),
5769
+            textCol.centerYAnchor.constraint(equalTo: preview.centerYAnchor),
5770
+            thumb.trailingAnchor.constraint(equalTo: preview.trailingAnchor, constant: -10),
5771
+            thumb.topAnchor.constraint(equalTo: preview.topAnchor, constant: 10),
5772
+            thumb.bottomAnchor.constraint(equalTo: preview.bottomAnchor, constant: -10)
5773
+        ])
5774
+        return preview
5775
+    }
5776
+
5777
+    private func makeInlineActionButton(title: String, url: URL) -> NSButton {
5778
+        let open = NSButton(title: title, target: self, action: #selector(openLinkPressed(_:)))
5779
+        open.bezelStyle = .inline
5780
+        open.identifier = NSUserInterfaceItemIdentifier(url.absoluteString)
5781
+        open.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
5782
+        return open
5783
+    }
5784
+
5785
+    private func makeStreamCardShell() -> NSView {
5786
+        let card = NSView()
5787
+        card.translatesAutoresizingMaskIntoConstraints = false
5788
+        card.wantsLayer = true
5789
+        card.layer?.cornerRadius = 10
5790
+        card.layer?.backgroundColor = palette.inputBackground
5791
+            .blended(withFraction: 0.32, of: palette.sectionCard)?
5792
+            .cgColor ?? palette.inputBackground.cgColor
5793
+        card.layer?.borderWidth = 1
5794
+        card.layer?.borderColor = palette.inputBorder.cgColor
5795
+        return card
5796
+    }
5797
+
5798
+    private func wrapInStreamCard(_ content: NSView) -> NSView {
5799
+        let card = makeStreamCardShell()
5800
+        card.addSubview(content)
5801
+        NSLayoutConstraint.activate([
5802
+            content.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
5803
+            content.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
5804
+            content.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
5805
+            content.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10),
5806
+            card.widthAnchor.constraint(greaterThanOrEqualToConstant: 220)
5807
+        ])
5808
+        return card
5809
+    }
5810
+
5811
+    private func timeText(_ date: Date?) -> String {
5812
+        guard let date else { return "Just now" }
5813
+        let formatter = DateFormatter()
5814
+        formatter.locale = Locale.current
5815
+        formatter.timeStyle = .short
5816
+        formatter.dateStyle = .none
5817
+        return formatter.string(from: date)
5818
+    }
5819
+}
5820
+
5408 5821
 private final class CalendarDayActionMenuViewController: NSViewController {
5409 5822
     private let palette: Palette
5410 5823
     private let onSchedule: () -> Void
@@ -6088,6 +6501,7 @@ private extension ViewController {
6088 6501
 
6089 6502
     private func renderEnrolledClassCards(_ courses: [ClassroomCourse]) {
6090 6503
         guard let stack = enrolledPageCardsStack else { return }
6504
+        enrolledCourseByCardID.removeAll()
6091 6505
 
6092 6506
         stack.arrangedSubviews.forEach { view in
6093 6507
             stack.removeArrangedSubview(view)
@@ -6121,7 +6535,7 @@ private extension ViewController {
6121 6535
 
6122 6536
         let isSingleCard = (courses.count == 1)
6123 6537
         for course in courses {
6124
-            let card = enrolledClassCard(course: course)
6538
+            let card = enrolledClassCardButton(course: course)
6125 6539
             stack.addArrangedSubview(card)
6126 6540
             if isSingleCard {
6127 6541
                 card.widthAnchor.constraint(equalToConstant: enrolledSingleCardWidth).isActive = true
@@ -6132,6 +6546,41 @@ private extension ViewController {
6132 6546
         }
6133 6547
     }
6134 6548
 
6549
+    private func enrolledClassCardButton(course: ClassroomCourse) -> NSView {
6550
+        let hit = HoverButton(title: "", target: self, action: #selector(enrolledCardButtonPressed(_:)))
6551
+        hit.translatesAutoresizingMaskIntoConstraints = false
6552
+        hit.isBordered = false
6553
+        hit.bezelStyle = .regularSquare
6554
+        hit.wantsLayer = true
6555
+        hit.identifier = NSUserInterfaceItemIdentifier(course.id)
6556
+        enrolledCourseByCardID[course.id] = course
6557
+        hit.heightAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true
6558
+
6559
+        let card = enrolledClassCard(course: course)
6560
+        hit.addSubview(card)
6561
+        NSLayoutConstraint.activate([
6562
+            card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
6563
+            card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
6564
+            card.topAnchor.constraint(equalTo: hit.topAnchor),
6565
+            card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
6566
+        ])
6567
+        hit.onHoverChanged = { [weak self, weak card] hovering in
6568
+            guard let self, let card else { return }
6569
+            let base = self.palette.sectionCard
6570
+            let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
6571
+            let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
6572
+            card.layer?.backgroundColor = (hovering ? hover : base).cgColor
6573
+        }
6574
+        hit.onHoverChanged?(false)
6575
+        return hit
6576
+    }
6577
+
6578
+    @objc private func enrolledCardButtonPressed(_ sender: NSButton) {
6579
+        guard let courseID = sender.identifier?.rawValue,
6580
+              let course = enrolledCourseByCardID[courseID] else { return }
6581
+        enrolledClassCardClicked(view: sender, course: course)
6582
+    }
6583
+
6135 6584
     private func enrolledClassCard(course: ClassroomCourse) -> NSView {
6136 6585
         let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
6137 6586
         card.translatesAutoresizingMaskIntoConstraints = false
@@ -6181,6 +6630,142 @@ private extension ViewController {
6181 6630
         return card
6182 6631
     }
6183 6632
 
6633
+    private func enrolledClassCardClicked(view: NSView, course: ClassroomCourse) {
6634
+        Task { [weak self, weak view] in
6635
+            guard let self, let view else { return }
6636
+            do {
6637
+                if self.googleOAuth.loadTokens() == nil {
6638
+                    await MainActor.run {
6639
+                        self.showSimpleAlert(title: "Connect Google", message: "Connect your Google account to view class details.")
6640
+                    }
6641
+                    return
6642
+                }
6643
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
6644
+                let details = try await self.classroomClient.fetchClassDetails(
6645
+                    accessToken: token,
6646
+                    courseId: course.id,
6647
+                    courseName: course.name
6648
+                )
6649
+                await MainActor.run {
6650
+                    let authorName = course.teacherNames.first ?? "Classroom"
6651
+                    self.showEnrolledClassDetailsPopover(details: details, defaultAuthorName: authorName, relativeTo: view)
6652
+                }
6653
+            } catch {
6654
+                await MainActor.run {
6655
+                    if self.errorRequiresReconsentForClassroomScopes(error) {
6656
+                        _ = try? self.googleOAuth.signOut()
6657
+                        self.applyGoogleProfile(nil)
6658
+                        self.updateGoogleAuthButtonTitle()
6659
+                        self.showSimpleAlert(
6660
+                            title: "Reconnect Google",
6661
+                            message: "We added new Google Classroom permissions for class announcements. Please reconnect your Google account and grant access."
6662
+                        )
6663
+                        return
6664
+                    }
6665
+                    self.showSimpleError("Couldn’t load class details.", error: error)
6666
+                }
6667
+            }
6668
+        }
6669
+    }
6670
+
6671
+    private func showEnrolledClassDetailsPopover(details: ClassroomClassDetails, defaultAuthorName: String, relativeTo anchor: NSView) {
6672
+        enrolledClassDetailsPopover?.performClose(nil)
6673
+        let popover = NSPopover()
6674
+        popover.behavior = .transient
6675
+        popover.animates = true
6676
+        popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
6677
+        popover.contentViewController = EnrolledClassDetailsViewController(
6678
+            details: details,
6679
+            palette: palette,
6680
+            typography: typography,
6681
+            defaultAuthorName: defaultAuthorName,
6682
+            defaultAuthorAvatar: scheduleProfileMenuAvatar,
6683
+            onOpenClass: { [weak self] in
6684
+                guard let self, let url = details.courseLink else { return }
6685
+                self.openURL(url)
6686
+            },
6687
+            onOpenURL: { [weak self] url in
6688
+                self?.openURL(url)
6689
+            },
6690
+            onDownloadAttachment: { [weak self] attachment in
6691
+                self?.downloadClassAttachment(attachment)
6692
+            }
6693
+        )
6694
+        enrolledClassDetailsPopover = popover
6695
+        popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX)
6696
+    }
6697
+
6698
+    private func downloadClassAttachment(_ attachment: ClassroomAttachment) {
6699
+        Task { [weak self] in
6700
+            guard let self else { return }
6701
+            do {
6702
+                let token = try? await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
6703
+                var request = URLRequest(url: attachment.url)
6704
+                request.httpMethod = "GET"
6705
+                if let token {
6706
+                    request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
6707
+                }
6708
+                let (data, response) = try await URLSession.shared.data(for: request)
6709
+                if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
6710
+                    if http.statusCode == 401 || http.statusCode == 403 {
6711
+                        await MainActor.run {
6712
+                            self.openURL(attachment.url)
6713
+                        }
6714
+                        return
6715
+                    }
6716
+                    let body = String(data: data, encoding: .utf8) ?? "<no body>"
6717
+                    throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
6718
+                }
6719
+                if let http = response as? HTTPURLResponse,
6720
+                   let contentType = http.value(forHTTPHeaderField: "Content-Type")?.lowercased(),
6721
+                   contentType.contains("text/html") {
6722
+                    await MainActor.run {
6723
+                        self.openURL(attachment.url)
6724
+                    }
6725
+                    return
6726
+                }
6727
+                let destination = try self.uniqueDownloadURL(for: attachment)
6728
+                try data.write(to: destination)
6729
+                await MainActor.run {
6730
+                    _ = NSWorkspace.shared.open(destination)
6731
+                }
6732
+            } catch {
6733
+                await MainActor.run {
6734
+                    self.showSimpleError("Couldn’t download attachment.", error: error)
6735
+                }
6736
+            }
6737
+        }
6738
+    }
6739
+
6740
+    private func uniqueDownloadURL(for attachment: ClassroomAttachment) throws -> URL {
6741
+        let fileManager = FileManager.default
6742
+        let downloadsDir = try fileManager.url(
6743
+            for: .downloadsDirectory,
6744
+            in: .userDomainMask,
6745
+            appropriateFor: nil,
6746
+            create: true
6747
+        )
6748
+
6749
+        let preferredName = attachment.title.trimmingCharacters(in: .whitespacesAndNewlines)
6750
+        let fallbackName = attachment.url.lastPathComponent.isEmpty ? "attachment" : attachment.url.lastPathComponent
6751
+        let originalName = (preferredName.isEmpty ? fallbackName : preferredName)
6752
+        let originalExtension = (attachment.url.pathExtension.isEmpty ? URL(fileURLWithPath: originalName).pathExtension : attachment.url.pathExtension)
6753
+        let baseName = URL(fileURLWithPath: originalName).deletingPathExtension().lastPathComponent
6754
+
6755
+        var candidate = downloadsDir.appendingPathComponent(originalName)
6756
+        if fileManager.fileExists(atPath: candidate.path) == false { return candidate }
6757
+
6758
+        var index = 1
6759
+        while true {
6760
+            let numbered = "\(baseName)-\(index)\(originalExtension.isEmpty ? "" : ".\(originalExtension)")"
6761
+            candidate = downloadsDir.appendingPathComponent(numbered)
6762
+            if fileManager.fileExists(atPath: candidate.path) == false {
6763
+                return candidate
6764
+            }
6765
+            index += 1
6766
+        }
6767
+    }
6768
+
6184 6769
     private func renderScheduleCards(into stack: NSStackView, todos: [ClassroomTodoItem]) {
6185 6770
         displayedScheduleTodos = todos
6186 6771
         let shouldShowScrollControls = todos.count > 3