|
|
@@ -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
|