|
|
@@ -272,6 +272,7 @@ final class ViewController: NSViewController {
|
|
272
|
272
|
private var inAppBrowserWindowController: InAppBrowserWindowController?
|
|
273
|
273
|
private let googleOAuth = GoogleOAuthService.shared
|
|
274
|
274
|
private let calendarClient = GoogleCalendarClient()
|
|
|
275
|
+ private let classroomClient = GoogleClassroomClient()
|
|
275
|
276
|
private let storeKitCoordinator = StoreKitCoordinator()
|
|
276
|
277
|
private var storeKitStartupTask: Task<Void, Never>?
|
|
277
|
278
|
private var paywallPurchaseTask: Task<Void, Never>?
|
|
|
@@ -285,7 +286,7 @@ final class ViewController: NSViewController {
|
|
285
|
286
|
private var launchPaywallWorkItem: DispatchWorkItem?
|
|
286
|
287
|
private var hasViewAppearedOnce = false
|
|
287
|
288
|
private var lastKnownPremiumAccess = false
|
|
288
|
|
- private var displayedScheduleMeetings: [ScheduledMeeting] = []
|
|
|
289
|
+ private var displayedScheduleTodos: [ClassroomTodoItem] = []
|
|
289
|
290
|
private var appUsageSessionStartDate: Date?
|
|
290
|
291
|
private var hasObservedAppLifecycleForUsage = false
|
|
291
|
292
|
private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
|
|
|
@@ -326,11 +327,12 @@ final class ViewController: NSViewController {
|
|
326
|
327
|
private var scheduleProfileImageTask: Task<Void, Never>?
|
|
327
|
328
|
private var googleAccountPopover: NSPopover?
|
|
328
|
329
|
private var scheduleCachedMeetings: [ScheduledMeeting] = []
|
|
|
330
|
+ private var scheduleCachedTodos: [ClassroomTodoItem] = []
|
|
329
|
331
|
|
|
330
|
332
|
private var schedulePageFilter: SchedulePageFilter = .all
|
|
331
|
333
|
private var schedulePageFromDate: Date = Calendar.current.startOfDay(for: Date())
|
|
332
|
334
|
private var schedulePageToDate: Date = Calendar.current.startOfDay(for: Date())
|
|
333
|
|
- private var schedulePageFilteredMeetings: [ScheduledMeeting] = []
|
|
|
335
|
+ private var schedulePageFilteredTodos: [ClassroomTodoItem] = []
|
|
334
|
336
|
private var schedulePageVisibleCount: Int = 0
|
|
335
|
337
|
private let schedulePageBatchSize: Int = 6
|
|
336
|
338
|
private let schedulePageCardsPerRow: Int = 3
|
|
|
@@ -1371,7 +1373,7 @@ private extension ViewController {
|
|
1371
|
1373
|
|
|
1372
|
1374
|
private func refreshScheduleCardsForPremiumStateChange() {
|
|
1373
|
1375
|
if let stack = scheduleCardsStack {
|
|
1374
|
|
- renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
|
|
|
1376
|
+ renderScheduleCards(into: stack, todos: displayedScheduleTodos)
|
|
1375
|
1377
|
}
|
|
1376
|
1378
|
applySchedulePageFiltersAndRender()
|
|
1377
|
1379
|
}
|
|
|
@@ -2123,7 +2125,7 @@ private extension ViewController {
|
|
2123
|
2125
|
contentStack.addArrangedSubview(dateHeading)
|
|
2124
|
2126
|
contentStack.setCustomSpacing(joinPageDateToMeetingCardsSpacing, after: dateHeading)
|
|
2125
|
2127
|
|
|
2126
|
|
- let cardsRow = scheduleCardsRow(meetings: [])
|
|
|
2128
|
+ let cardsRow = scheduleCardsRow(todos: [])
|
|
2127
|
2129
|
contentStack.addArrangedSubview(cardsRow)
|
|
2128
|
2130
|
|
|
2129
|
2131
|
panel.addSubview(contentStack)
|
|
|
@@ -3265,7 +3267,7 @@ private extension ViewController {
|
|
3265
|
3267
|
row.distribution = .fill
|
|
3266
|
3268
|
row.spacing = 12
|
|
3267
|
3269
|
|
|
3268
|
|
- row.addArrangedSubview(textLabel("Schedule", font: typography.sectionTitleBold, color: palette.textPrimary))
|
|
|
3270
|
+ row.addArrangedSubview(textLabel("To-do", font: typography.sectionTitleBold, color: palette.textPrimary))
|
|
3269
|
3271
|
|
|
3270
|
3272
|
let spacer = NSView()
|
|
3271
|
3273
|
spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -3295,7 +3297,7 @@ private extension ViewController {
|
|
3295
|
3297
|
titleRow.distribution = .fill
|
|
3296
|
3298
|
titleRow.spacing = 0
|
|
3297
|
3299
|
|
|
3298
|
|
- let titleLabel = textLabel("Schedule", font: typography.pageTitle, color: palette.textPrimary)
|
|
|
3300
|
+ let titleLabel = textLabel("To-do", font: typography.pageTitle, color: palette.textPrimary)
|
|
3299
|
3301
|
titleLabel.alignment = .left
|
|
3300
|
3302
|
titleLabel.userInterfaceLayoutDirection = .leftToRight
|
|
3301
|
3303
|
titleLabel.maximumNumberOfLines = 1
|
|
|
@@ -3309,10 +3311,7 @@ private extension ViewController {
|
|
3309
|
3311
|
titleRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
3310
|
3312
|
|
|
3311
|
3313
|
titleRow.addArrangedSubview(titleLabel)
|
|
3312
|
|
- if googleOAuth.loadTokens() != nil && storeKitCoordinator.hasPremiumAccess {
|
|
3313
|
|
- titleRow.addArrangedSubview(makeSchedulePageAddButton())
|
|
3314
|
|
- titleRow.setCustomSpacing(12, after: titleLabel)
|
|
3315
|
|
- }
|
|
|
3314
|
+ // To-do items are read-only from Classroom.
|
|
3316
|
3315
|
titleRow.addArrangedSubview(titleRowSpacer)
|
|
3317
|
3316
|
container.addArrangedSubview(titleRow)
|
|
3318
|
3317
|
|
|
|
@@ -3725,7 +3724,7 @@ private extension ViewController {
|
|
3725
|
3724
|
return button
|
|
3726
|
3725
|
}
|
|
3727
|
3726
|
|
|
3728
|
|
- func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
|
|
|
3727
|
+ func scheduleCardsRow(todos: [ClassroomTodoItem]) -> NSView {
|
|
3729
|
3728
|
let cardWidth: CGFloat = 240
|
|
3730
|
3729
|
let cardsPerViewport: CGFloat = 3
|
|
3731
|
3730
|
let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
|
|
|
@@ -3774,7 +3773,7 @@ private extension ViewController {
|
|
3774
|
3773
|
row.heightAnchor.constraint(equalToConstant: 150)
|
|
3775
|
3774
|
])
|
|
3776
|
3775
|
|
|
3777
|
|
- renderScheduleCards(into: row, meetings: meetings)
|
|
|
3776
|
+ renderScheduleCards(into: row, todos: todos)
|
|
3778
|
3777
|
wrapper.addArrangedSubview(scroll)
|
|
3779
|
3778
|
let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
|
|
3780
|
3779
|
scheduleScrollRightButton = rightButton
|
|
|
@@ -3785,7 +3784,7 @@ private extension ViewController {
|
|
3785
|
3784
|
return wrapper
|
|
3786
|
3785
|
}
|
|
3787
|
3786
|
|
|
3788
|
|
- func scheduleCard(meeting: ScheduledMeeting, useFlexibleWidth: Bool = false, contentHeight: CGFloat = 150) -> NSView {
|
|
|
3787
|
+ func scheduleCard(todo: ClassroomTodoItem, useFlexibleWidth: Bool = false, contentHeight: CGFloat = 150) -> NSView {
|
|
3789
|
3788
|
let cardWidth: CGFloat = 240
|
|
3790
|
3789
|
|
|
3791
|
3790
|
let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
|
|
|
@@ -3807,7 +3806,17 @@ private extension ViewController {
|
|
3807
|
3806
|
icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
3808
|
3807
|
let iconView = NSImageView()
|
|
3809
|
3808
|
iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
3810
|
|
- iconView.image = NSImage(systemSymbolName: "book.closed.fill", accessibilityDescription: "Class event")
|
|
|
3809
|
+ let iconSymbolName: String = {
|
|
|
3810
|
+ switch todo.workType {
|
|
|
3811
|
+ case .assignment:
|
|
|
3812
|
+ return "doc.text.fill"
|
|
|
3813
|
+ case .shortAnswerQuestion, .multipleChoiceQuestion:
|
|
|
3814
|
+ return "checkmark.seal.fill"
|
|
|
3815
|
+ case .unspecified:
|
|
|
3816
|
+ return "book.closed.fill"
|
|
|
3817
|
+ }
|
|
|
3818
|
+ }()
|
|
|
3819
|
+ iconView.image = NSImage(systemSymbolName: iconSymbolName, accessibilityDescription: "Classroom to-do")
|
|
3811
|
3820
|
iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
|
3812
|
3821
|
iconView.contentTintColor = .white
|
|
3813
|
3822
|
icon.addSubview(iconView)
|
|
|
@@ -3816,18 +3825,18 @@ private extension ViewController {
|
|
3816
|
3825
|
iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
|
|
3817
|
3826
|
])
|
|
3818
|
3827
|
|
|
3819
|
|
- let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
|
|
|
3828
|
+ let title = textLabel(todo.title, font: typography.cardTitle, color: palette.textPrimary)
|
|
3820
|
3829
|
title.lineBreakMode = .byTruncatingTail
|
|
3821
|
3830
|
title.maximumNumberOfLines = 1
|
|
3822
|
3831
|
title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
3823
|
|
- let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
|
|
3824
|
|
- let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
|
|
3825
|
|
- let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
|
|
|
3832
|
+ let subtitle = textLabel(todo.courseName, font: typography.cardSubtitle, color: palette.textPrimary)
|
|
|
3833
|
+ let time = textLabel(todoDueText(for: todo), font: typography.cardTime, color: palette.textSecondary)
|
|
|
3834
|
+ let duration = textLabel(todo.workType.displayName, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
|
|
3826
|
3835
|
let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
|
|
3827
|
3836
|
dayChip.translatesAutoresizingMaskIntoConstraints = false
|
|
3828
|
3837
|
dayChip.layer?.borderWidth = 1
|
|
3829
|
3838
|
dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
|
|
3830
|
|
- let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
|
|
|
3839
|
+ let dayText = textLabel(todoDayText(for: todo), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
|
|
3831
|
3840
|
dayText.translatesAutoresizingMaskIntoConstraints = false
|
|
3832
|
3841
|
dayChip.addSubview(dayText)
|
|
3833
|
3842
|
NSLayoutConstraint.activate([
|
|
|
@@ -3876,7 +3885,9 @@ private extension ViewController {
|
|
3876
|
3885
|
hit.translatesAutoresizingMaskIntoConstraints = false
|
|
3877
|
3886
|
hit.isBordered = false
|
|
3878
|
3887
|
hit.bezelStyle = .regularSquare
|
|
3879
|
|
- hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
|
|
|
3888
|
+ if let url = todo.alternateLink {
|
|
|
3889
|
+ hit.identifier = NSUserInterfaceItemIdentifier(url.absoluteString)
|
|
|
3890
|
+ }
|
|
3880
|
3891
|
hit.heightAnchor.constraint(equalToConstant: contentHeight).isActive = true
|
|
3881
|
3892
|
if useFlexibleWidth {
|
|
3882
|
3893
|
hit.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
@@ -5815,7 +5826,7 @@ private extension ViewController {
|
|
5815
|
5826
|
}
|
|
5816
|
5827
|
guard let raw = sender.identifier?.rawValue,
|
|
5817
|
5828
|
let url = URL(string: raw) else { return }
|
|
5818
|
|
- openMeetingURL(url)
|
|
|
5829
|
+ openURL(url)
|
|
5819
|
5830
|
}
|
|
5820
|
5831
|
|
|
5821
|
5832
|
@objc func scheduleConnectButtonPressed(_ sender: NSButton) {
|
|
|
@@ -5827,11 +5838,11 @@ private extension ViewController {
|
|
5827
|
5838
|
}
|
|
5828
|
5839
|
|
|
5829
|
5840
|
private func scheduleInitialHeadingText() -> String {
|
|
5830
|
|
- googleOAuth.loadTokens() == nil ? "Connect Google to see your calendar" : "Loading…"
|
|
|
5841
|
+ googleOAuth.loadTokens() == nil ? "Connect Google to see your to-do" : "Loading…"
|
|
5831
|
5842
|
}
|
|
5832
|
5843
|
|
|
5833
|
5844
|
private func schedulePageInitialHeadingText() -> String {
|
|
5834
|
|
- googleOAuth.loadTokens() == nil ? "Connect Google to see your calendar" : "Loading schedule…"
|
|
|
5845
|
+ googleOAuth.loadTokens() == nil ? "Connect Google to see your to-do" : "Loading to-do…"
|
|
5835
|
5846
|
}
|
|
5836
|
5847
|
|
|
5837
|
5848
|
@objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
|
|
|
@@ -5907,41 +5918,31 @@ private extension ViewController {
|
|
5907
|
5918
|
presentCreateMeetingPopover(relativeTo: sender)
|
|
5908
|
5919
|
}
|
|
5909
|
5920
|
|
|
5910
|
|
- private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
|
|
5911
|
|
- if meeting.isAllDay { return "All day" }
|
|
|
5921
|
+ private func todoDueText(for todo: ClassroomTodoItem) -> String {
|
|
|
5922
|
+ guard let due = todo.dueDate else { return "No due date" }
|
|
5912
|
5923
|
let f = DateFormatter()
|
|
5913
|
5924
|
f.locale = Locale.current
|
|
5914
|
5925
|
f.timeZone = TimeZone.current
|
|
5915
|
5926
|
f.dateStyle = .none
|
|
5916
|
5927
|
f.timeStyle = .short
|
|
5917
|
|
- return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
|
|
|
5928
|
+ return "Due \(f.string(from: due))"
|
|
5918
|
5929
|
}
|
|
5919
|
5930
|
|
|
5920
|
|
- private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
|
|
|
5931
|
+ private func todoDayText(for todo: ClassroomTodoItem) -> String {
|
|
|
5932
|
+ guard let due = todo.dueDate else { return "Anytime" }
|
|
5921
|
5933
|
let f = DateFormatter()
|
|
5922
|
5934
|
f.locale = Locale.current
|
|
5923
|
5935
|
f.timeZone = TimeZone.current
|
|
5924
|
5936
|
f.dateFormat = "EEE, d MMM"
|
|
5925
|
|
- return f.string(from: meeting.startDate)
|
|
5926
|
|
- }
|
|
5927
|
|
-
|
|
5928
|
|
- private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
|
|
5929
|
|
- if meeting.isAllDay { return "Duration: all day" }
|
|
5930
|
|
- let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
|
|
5931
|
|
- let totalMinutes = Int(duration / 60)
|
|
5932
|
|
- let hours = totalMinutes / 60
|
|
5933
|
|
- let minutes = totalMinutes % 60
|
|
5934
|
|
- if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
|
|
5935
|
|
- if hours > 0 { return "Duration: \(hours)h" }
|
|
5936
|
|
- return "Duration: \(minutes)m"
|
|
|
5937
|
+ return f.string(from: due)
|
|
5937
|
5938
|
}
|
|
5938
|
5939
|
|
|
5939
|
|
- private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
|
|
5940
|
|
- guard let first = meetings.first else {
|
|
5941
|
|
- return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
|
|
|
5940
|
+ private func scheduleHeadingText(for todos: [ClassroomTodoItem]) -> String {
|
|
|
5941
|
+ guard let first = todos.first else {
|
|
|
5942
|
+ return googleOAuth.loadTokens() == nil ? "Connect Google to see to-do" : "No upcoming work"
|
|
5942
|
5943
|
}
|
|
5943
|
|
-
|
|
5944
|
|
- let day = Calendar.current.startOfDay(for: first.startDate)
|
|
|
5944
|
+ guard let due = first.dueDate else { return "No due dates" }
|
|
|
5945
|
+ let day = Calendar.current.startOfDay(for: due)
|
|
5945
|
5946
|
let f = DateFormatter()
|
|
5946
|
5947
|
f.locale = Locale.current
|
|
5947
|
5948
|
f.timeZone = TimeZone.current
|
|
|
@@ -5949,13 +5950,13 @@ private extension ViewController {
|
|
5949
|
5950
|
return f.string(from: day)
|
|
5950
|
5951
|
}
|
|
5951
|
5952
|
|
|
5952
|
|
- private func openMeetingURL(_ url: URL) {
|
|
|
5953
|
+ private func openURL(_ url: URL) {
|
|
5953
|
5954
|
NSWorkspace.shared.open(url)
|
|
5954
|
5955
|
}
|
|
5955
|
5956
|
|
|
5956
|
|
- private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
|
|
5957
|
|
- displayedScheduleMeetings = meetings
|
|
5958
|
|
- let shouldShowScrollControls = meetings.count > 3
|
|
|
5957
|
+ private func renderScheduleCards(into stack: NSStackView, todos: [ClassroomTodoItem]) {
|
|
|
5958
|
+ displayedScheduleTodos = todos
|
|
|
5959
|
+ let shouldShowScrollControls = todos.count > 3
|
|
5959
|
5960
|
scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
|
|
5960
|
5961
|
scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
|
|
5961
|
5962
|
scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
|
|
|
@@ -5968,14 +5969,14 @@ private extension ViewController {
|
|
5968
|
5969
|
v.removeFromSuperview()
|
|
5969
|
5970
|
}
|
|
5970
|
5971
|
|
|
5971
|
|
- if meetings.isEmpty {
|
|
|
5972
|
+ if todos.isEmpty {
|
|
5972
|
5973
|
let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
5973
|
5974
|
empty.translatesAutoresizingMaskIntoConstraints = false
|
|
5974
|
5975
|
empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
|
|
5975
|
5976
|
empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
5976
|
5977
|
styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
5977
|
5978
|
|
|
5978
|
|
- let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
|
|
|
5979
|
+ let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load to-do" : "No work due", font: typography.cardSubtitle, color: palette.textSecondary)
|
|
5979
|
5980
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
5980
|
5981
|
empty.addSubview(label)
|
|
5981
|
5982
|
NSLayoutConstraint.activate([
|
|
|
@@ -5986,50 +5987,65 @@ private extension ViewController {
|
|
5986
|
5987
|
return
|
|
5987
|
5988
|
}
|
|
5988
|
5989
|
|
|
5989
|
|
- for meeting in meetings {
|
|
5990
|
|
- stack.addArrangedSubview(scheduleCard(meeting: meeting))
|
|
|
5990
|
+ for todo in todos {
|
|
|
5991
|
+ stack.addArrangedSubview(scheduleCard(todo: todo))
|
|
5991
|
5992
|
}
|
|
5992
|
5993
|
}
|
|
5993
|
5994
|
|
|
5994
|
|
- private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
|
|
|
5995
|
+ private func filteredTodos(_ todos: [ClassroomTodoItem]) -> [ClassroomTodoItem] {
|
|
5995
|
5996
|
switch scheduleFilter {
|
|
5996
|
5997
|
case .all:
|
|
5997
|
|
- return meetings
|
|
|
5998
|
+ return todos
|
|
5998
|
5999
|
case .today:
|
|
5999
|
6000
|
let start = Calendar.current.startOfDay(for: Date())
|
|
6000
|
6001
|
let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
|
|
6001
|
|
- return meetings.filter { $0.startDate >= start && $0.startDate < end }
|
|
|
6002
|
+ return todos.filter { ($0.dueDate ?? .distantPast) >= start && ($0.dueDate ?? .distantPast) < end }
|
|
6002
|
6003
|
case .week:
|
|
6003
|
6004
|
let now = Date()
|
|
6004
|
6005
|
let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
|
|
6005
|
|
- return meetings.filter { $0.startDate >= now && $0.startDate <= end }
|
|
|
6006
|
+ return todos.filter {
|
|
|
6007
|
+ guard let due = $0.dueDate else { return false }
|
|
|
6008
|
+ return due >= now && due <= end
|
|
|
6009
|
+ }
|
|
6006
|
6010
|
}
|
|
6007
|
6011
|
}
|
|
6008
|
6012
|
|
|
6009
|
|
- private func filteredMeetingsForSchedulePage(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
|
|
|
6013
|
+ private func filteredTodosForSchedulePage(_ todos: [ClassroomTodoItem]) -> [ClassroomTodoItem] {
|
|
6010
|
6014
|
let calendar = Calendar.current
|
|
6011
|
6015
|
switch schedulePageFilter {
|
|
6012
|
6016
|
case .all:
|
|
6013
|
|
- return meetings
|
|
|
6017
|
+ return todos
|
|
6014
|
6018
|
case .today:
|
|
6015
|
6019
|
let start = calendar.startOfDay(for: Date())
|
|
6016
|
6020
|
let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
|
|
6017
|
|
- return meetings.filter { $0.startDate >= start && $0.startDate < end }
|
|
|
6021
|
+ return todos.filter {
|
|
|
6022
|
+ guard let due = $0.dueDate else { return false }
|
|
|
6023
|
+ return due >= start && due < end
|
|
|
6024
|
+ }
|
|
6018
|
6025
|
case .week:
|
|
6019
|
6026
|
let now = Date()
|
|
6020
|
6027
|
let end = calendar.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
|
|
6021
|
|
- return meetings.filter { $0.startDate >= now && $0.startDate <= end }
|
|
|
6028
|
+ return todos.filter {
|
|
|
6029
|
+ guard let due = $0.dueDate else { return false }
|
|
|
6030
|
+ return due >= now && due <= end
|
|
|
6031
|
+ }
|
|
6022
|
6032
|
case .month:
|
|
6023
|
6033
|
let now = Date()
|
|
6024
|
6034
|
let end = calendar.date(byAdding: .month, value: 1, to: now) ?? now.addingTimeInterval(30 * 86400)
|
|
6025
|
|
- return meetings.filter { $0.startDate >= now && $0.startDate <= end }
|
|
|
6035
|
+ return todos.filter {
|
|
|
6036
|
+ guard let due = $0.dueDate else { return false }
|
|
|
6037
|
+ return due >= now && due <= end
|
|
|
6038
|
+ }
|
|
6026
|
6039
|
case .customRange:
|
|
6027
|
6040
|
let start = calendar.startOfDay(for: schedulePageFromDate)
|
|
6028
|
6041
|
let inclusiveEndDay = calendar.startOfDay(for: schedulePageToDate)
|
|
6029
|
6042
|
guard let end = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else {
|
|
6030
|
|
- return meetings
|
|
|
6043
|
+ return todos
|
|
|
6044
|
+ }
|
|
|
6045
|
+ return todos.filter {
|
|
|
6046
|
+ guard let due = $0.dueDate else { return false }
|
|
|
6047
|
+ return due >= start && due < end
|
|
6031
|
6048
|
}
|
|
6032
|
|
- return meetings.filter { $0.startDate >= start && $0.startDate < end }
|
|
6033
|
6049
|
}
|
|
6034
|
6050
|
}
|
|
6035
|
6051
|
|
|
|
@@ -6061,7 +6077,7 @@ private extension ViewController {
|
|
6061
|
6077
|
schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
|
|
6062
|
6078
|
if schedulePageFilter == .customRange && !schedulePageHasValidCustomRange() {
|
|
6063
|
6079
|
setSchedulePageRangeError("Start date must be on or before end date.")
|
|
6064
|
|
- schedulePageFilteredMeetings = []
|
|
|
6080
|
+ schedulePageFilteredTodos = []
|
|
6065
|
6081
|
schedulePageVisibleCount = 0
|
|
6066
|
6082
|
renderSchedulePageCards()
|
|
6067
|
6083
|
schedulePageDateHeadingLabel?.stringValue = "Invalid custom date range"
|
|
|
@@ -6069,15 +6085,15 @@ private extension ViewController {
|
|
6069
|
6085
|
}
|
|
6070
|
6086
|
|
|
6071
|
6087
|
setSchedulePageRangeError(nil)
|
|
6072
|
|
- schedulePageFilteredMeetings = filteredMeetingsForSchedulePage(scheduleCachedMeetings)
|
|
6073
|
|
- schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredMeetings.count)
|
|
|
6088
|
+ schedulePageFilteredTodos = filteredTodosForSchedulePage(scheduleCachedTodos)
|
|
|
6089
|
+ schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredTodos.count)
|
|
6074
|
6090
|
renderSchedulePageCards()
|
|
6075
|
|
- schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredMeetings)
|
|
|
6091
|
+ schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredTodos)
|
|
6076
|
6092
|
}
|
|
6077
|
6093
|
|
|
6078
|
6094
|
private func appendSchedulePageBatchIfNeeded() {
|
|
6079
|
|
- guard schedulePageVisibleCount < schedulePageFilteredMeetings.count else { return }
|
|
6080
|
|
- let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredMeetings.count)
|
|
|
6095
|
+ guard schedulePageVisibleCount < schedulePageFilteredTodos.count else { return }
|
|
|
6096
|
+ let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredTodos.count)
|
|
6081
|
6097
|
guard nextCount > schedulePageVisibleCount else { return }
|
|
6082
|
6098
|
schedulePageVisibleCount = nextCount
|
|
6083
|
6099
|
renderSchedulePageCards()
|
|
|
@@ -6106,13 +6122,13 @@ private extension ViewController {
|
|
6106
|
6122
|
v.removeFromSuperview()
|
|
6107
|
6123
|
}
|
|
6108
|
6124
|
|
|
6109
|
|
- let visibleMeetings = Array(schedulePageFilteredMeetings.prefix(schedulePageVisibleCount))
|
|
6110
|
|
- if visibleMeetings.isEmpty {
|
|
|
6125
|
+ let visibleTodos = Array(schedulePageFilteredTodos.prefix(schedulePageVisibleCount))
|
|
|
6126
|
+ if visibleTodos.isEmpty {
|
|
6111
|
6127
|
let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
6112
|
6128
|
empty.translatesAutoresizingMaskIntoConstraints = false
|
|
6113
|
6129
|
empty.heightAnchor.constraint(equalToConstant: 140).isActive = true
|
|
6114
|
6130
|
styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
6115
|
|
- let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
|
|
|
6131
|
+ let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load to-do" : "No work for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
|
|
6116
|
6132
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
6117
|
6133
|
empty.addSubview(label)
|
|
6118
|
6134
|
NSLayoutConstraint.activate([
|
|
|
@@ -6125,7 +6141,7 @@ private extension ViewController {
|
|
6125
|
6141
|
}
|
|
6126
|
6142
|
|
|
6127
|
6143
|
var index = 0
|
|
6128
|
|
- while index < visibleMeetings.count {
|
|
|
6144
|
+ while index < visibleTodos.count {
|
|
6129
|
6145
|
let row = NSStackView()
|
|
6130
|
6146
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
6131
|
6147
|
row.userInterfaceLayoutDirection = .leftToRight
|
|
|
@@ -6133,16 +6149,16 @@ private extension ViewController {
|
|
6133
|
6149
|
row.alignment = .top
|
|
6134
|
6150
|
row.spacing = schedulePageCardSpacing
|
|
6135
|
6151
|
row.distribution = .fillEqually
|
|
6136
|
|
- let rowEnd = min(index + schedulePageCardsPerRow, visibleMeetings.count)
|
|
6137
|
|
- for meeting in visibleMeetings[index..<rowEnd] {
|
|
6138
|
|
- row.addArrangedSubview(scheduleCard(meeting: meeting, useFlexibleWidth: true, contentHeight: schedulePageCardHeight))
|
|
|
6152
|
+ let rowEnd = min(index + schedulePageCardsPerRow, visibleTodos.count)
|
|
|
6153
|
+ for todo in visibleTodos[index..<rowEnd] {
|
|
|
6154
|
+ row.addArrangedSubview(scheduleCard(todo: todo, useFlexibleWidth: true, contentHeight: schedulePageCardHeight))
|
|
6139
|
6155
|
}
|
|
6140
|
6156
|
stack.addArrangedSubview(row)
|
|
6141
|
6157
|
row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
6142
|
6158
|
index = rowEnd
|
|
6143
|
6159
|
}
|
|
6144
|
6160
|
|
|
6145
|
|
- if schedulePageVisibleCount < schedulePageFilteredMeetings.count {
|
|
|
6161
|
+ if schedulePageVisibleCount < schedulePageFilteredTodos.count {
|
|
6146
|
6162
|
let pagination = NSStackView()
|
|
6147
|
6163
|
pagination.translatesAutoresizingMaskIntoConstraints = false
|
|
6148
|
6164
|
pagination.orientation = .horizontal
|
|
|
@@ -6150,7 +6166,7 @@ private extension ViewController {
|
|
6150
|
6166
|
pagination.spacing = 10
|
|
6151
|
6167
|
|
|
6152
|
6168
|
let moreLabel = textLabel(
|
|
6153
|
|
- "Showing \(schedulePageVisibleCount) of \(schedulePageFilteredMeetings.count)",
|
|
|
6169
|
+ "Showing \(schedulePageVisibleCount) of \(schedulePageFilteredTodos.count)",
|
|
6154
|
6170
|
font: NSFont.systemFont(ofSize: 12, weight: .medium),
|
|
6155
|
6171
|
color: palette.textMuted
|
|
6156
|
6172
|
)
|
|
|
@@ -6177,18 +6193,27 @@ private extension ViewController {
|
|
6177
|
6193
|
scroll.reflectScrolledClipView(scroll.contentView)
|
|
6178
|
6194
|
}
|
|
6179
|
6195
|
|
|
|
6196
|
+ private func errorRequiresReconsentForClassroomScopes(_ error: Error) -> Bool {
|
|
|
6197
|
+ if case let GoogleClassroomClientError.httpStatus(status, body) = error {
|
|
|
6198
|
+ guard status == 403 else { return false }
|
|
|
6199
|
+ return body.contains("ACCESS_TOKEN_SCOPE_INSUFFICIENT") || body.contains("insufficient authentication scopes")
|
|
|
6200
|
+ }
|
|
|
6201
|
+ return false
|
|
|
6202
|
+ }
|
|
|
6203
|
+
|
|
6180
|
6204
|
private func loadSchedule() async {
|
|
6181
|
6205
|
do {
|
|
6182
|
6206
|
if googleOAuth.loadTokens() == nil {
|
|
6183
|
6207
|
await MainActor.run {
|
|
6184
|
6208
|
updateGoogleAuthButtonTitle()
|
|
6185
|
6209
|
applyGoogleProfile(nil)
|
|
6186
|
|
- scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
6187
|
|
- schedulePageDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
6210
|
+ scheduleDateHeadingLabel?.stringValue = "Connect Google to see your to-do"
|
|
|
6211
|
+ schedulePageDateHeadingLabel?.stringValue = "Connect Google to see your to-do"
|
|
6188
|
6212
|
if let stack = scheduleCardsStack {
|
|
6189
|
|
- renderScheduleCards(into: stack, meetings: [])
|
|
|
6213
|
+ renderScheduleCards(into: stack, todos: [])
|
|
6190
|
6214
|
}
|
|
6191
|
6215
|
scheduleCachedMeetings = []
|
|
|
6216
|
+ scheduleCachedTodos = []
|
|
6192
|
6217
|
applySchedulePageFiltersAndRender()
|
|
6193
|
6218
|
if calendarPageGridStack != nil {
|
|
6194
|
6219
|
calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
@@ -6201,17 +6226,19 @@ private extension ViewController {
|
|
6201
|
6226
|
|
|
6202
|
6227
|
let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
6203
|
6228
|
let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
6229
|
+ let todos = try await classroomClient.fetchTodo(accessToken: token)
|
|
|
6230
|
+ let filtered = filteredTodos(todos)
|
|
6204
|
6231
|
let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
|
|
6205
|
|
- let filtered = filteredMeetings(meetings)
|
|
6206
|
6232
|
|
|
6207
|
6233
|
await MainActor.run {
|
|
6208
|
6234
|
updateGoogleAuthButtonTitle()
|
|
6209
|
6235
|
applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
6210
|
6236
|
scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
|
|
6211
|
6237
|
if let stack = scheduleCardsStack {
|
|
6212
|
|
- renderScheduleCards(into: stack, meetings: filtered)
|
|
|
6238
|
+ renderScheduleCards(into: stack, todos: filtered)
|
|
6213
|
6239
|
}
|
|
6214
|
6240
|
scheduleCachedMeetings = meetings
|
|
|
6241
|
+ scheduleCachedTodos = todos
|
|
6215
|
6242
|
applySchedulePageFiltersAndRender()
|
|
6216
|
6243
|
if calendarPageGridStack != nil {
|
|
6217
|
6244
|
calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
@@ -6221,31 +6248,55 @@ private extension ViewController {
|
|
6221
|
6248
|
}
|
|
6222
|
6249
|
} catch {
|
|
6223
|
6250
|
await MainActor.run {
|
|
|
6251
|
+ if errorRequiresReconsentForClassroomScopes(error) {
|
|
|
6252
|
+ _ = try? googleOAuth.signOut()
|
|
|
6253
|
+ applyGoogleProfile(nil)
|
|
|
6254
|
+ updateGoogleAuthButtonTitle()
|
|
|
6255
|
+ scheduleDateHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions"
|
|
|
6256
|
+ schedulePageDateHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions"
|
|
|
6257
|
+ if let stack = scheduleCardsStack {
|
|
|
6258
|
+ renderScheduleCards(into: stack, todos: [])
|
|
|
6259
|
+ }
|
|
|
6260
|
+ scheduleCachedMeetings = []
|
|
|
6261
|
+ scheduleCachedTodos = []
|
|
|
6262
|
+ applySchedulePageFiltersAndRender()
|
|
|
6263
|
+ if calendarPageGridStack != nil {
|
|
|
6264
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
6265
|
+ renderCalendarMonthGrid()
|
|
|
6266
|
+ renderCalendarSelectedDay()
|
|
|
6267
|
+ }
|
|
|
6268
|
+ showSimpleAlert(
|
|
|
6269
|
+ title: "Reconnect Google",
|
|
|
6270
|
+ message: "We added Google Classroom permissions. Please connect your Google account again so Google can grant access to assignments and quizzes."
|
|
|
6271
|
+ )
|
|
|
6272
|
+ return
|
|
|
6273
|
+ }
|
|
6224
|
6274
|
updateGoogleAuthButtonTitle()
|
|
6225
|
6275
|
if googleOAuth.loadTokens() == nil {
|
|
6226
|
6276
|
applyGoogleProfile(nil)
|
|
6227
|
6277
|
}
|
|
6228
|
|
- scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
6229
|
|
- schedulePageDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
|
6278
|
+ scheduleDateHeadingLabel?.stringValue = "Couldn’t load to-do"
|
|
|
6279
|
+ schedulePageDateHeadingLabel?.stringValue = "Couldn’t load to-do"
|
|
6230
|
6280
|
if let stack = scheduleCardsStack {
|
|
6231
|
|
- renderScheduleCards(into: stack, meetings: [])
|
|
|
6281
|
+ renderScheduleCards(into: stack, todos: [])
|
|
6232
|
6282
|
}
|
|
6233
|
6283
|
scheduleCachedMeetings = []
|
|
|
6284
|
+ scheduleCachedTodos = []
|
|
6234
|
6285
|
applySchedulePageFiltersAndRender()
|
|
6235
|
6286
|
if calendarPageGridStack != nil {
|
|
6236
|
6287
|
calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
6237
|
6288
|
renderCalendarMonthGrid()
|
|
6238
|
6289
|
renderCalendarSelectedDay()
|
|
6239
|
6290
|
}
|
|
6240
|
|
- showSimpleError("Couldn’t load schedule.", error: error)
|
|
|
6291
|
+ showSimpleError("Couldn’t load to-do.", error: error)
|
|
6241
|
6292
|
}
|
|
6242
|
6293
|
}
|
|
6243
|
6294
|
}
|
|
6244
|
6295
|
|
|
6245
|
6296
|
func showScheduleHelp() {
|
|
6246
|
6297
|
let alert = NSAlert()
|
|
6247
|
|
- alert.messageText = "Google schedule"
|
|
6248
|
|
- alert.informativeText = "To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
|
|
|
6298
|
+ alert.messageText = "Google Classroom to-do"
|
|
|
6299
|
+ alert.informativeText = "To show assignments and quizzes, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
|
|
6249
|
6300
|
alert.addButton(withTitle: "OK")
|
|
6250
|
6301
|
alert.runModal()
|
|
6251
|
6302
|
}
|