|
@@ -292,6 +292,14 @@ final class ViewController: NSViewController {
|
|
|
case week = 2
|
|
case week = 2
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private enum SchedulePageFilter: Int {
|
|
|
|
|
+ case all = 0
|
|
|
|
|
+ case today = 1
|
|
|
|
|
+ case week = 2
|
|
|
|
|
+ case month = 3
|
|
|
|
|
+ case customRange = 4
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private var scheduleFilter: ScheduleFilter = .all
|
|
private var scheduleFilter: ScheduleFilter = .all
|
|
|
private weak var scheduleDateHeadingLabel: NSTextField?
|
|
private weak var scheduleDateHeadingLabel: NSTextField?
|
|
|
private weak var scheduleCardsStack: NSStackView?
|
|
private weak var scheduleCardsStack: NSStackView?
|
|
@@ -313,6 +321,25 @@ final class ViewController: NSViewController {
|
|
|
private var scheduleProfileMenuAvatar: NSImage?
|
|
private var scheduleProfileMenuAvatar: NSImage?
|
|
|
private var scheduleProfileImageTask: Task<Void, Never>?
|
|
private var scheduleProfileImageTask: Task<Void, Never>?
|
|
|
private var googleAccountPopover: NSPopover?
|
|
private var googleAccountPopover: NSPopover?
|
|
|
|
|
+ private var scheduleCachedMeetings: [ScheduledMeeting] = []
|
|
|
|
|
+
|
|
|
|
|
+ private var schedulePageFilter: SchedulePageFilter = .all
|
|
|
|
|
+ private var schedulePageFromDate: Date = Calendar.current.startOfDay(for: Date())
|
|
|
|
|
+ private var schedulePageToDate: Date = Calendar.current.startOfDay(for: Date())
|
|
|
|
|
+ private var schedulePageFilteredMeetings: [ScheduledMeeting] = []
|
|
|
|
|
+ private var schedulePageVisibleCount: Int = 0
|
|
|
|
|
+ private let schedulePageBatchSize: Int = 20
|
|
|
|
|
+ private let schedulePageCardsPerRow: Int = 3
|
|
|
|
|
+ private let schedulePageCardWidth: CGFloat = 240
|
|
|
|
|
+ private let schedulePageCardSpacing: CGFloat = 12
|
|
|
|
|
+ private var schedulePageScrollObservation: NSObjectProtocol?
|
|
|
|
|
+ private weak var schedulePageDateHeadingLabel: NSTextField?
|
|
|
|
|
+ private weak var schedulePageFilterDropdown: NSPopUpButton?
|
|
|
|
|
+ private weak var schedulePageFromDatePicker: NSDatePicker?
|
|
|
|
|
+ private weak var schedulePageToDatePicker: NSDatePicker?
|
|
|
|
|
+ private weak var schedulePageRangeErrorLabel: NSTextField?
|
|
|
|
|
+ private weak var schedulePageCardsStack: NSStackView?
|
|
|
|
|
+ private weak var schedulePageCardsScrollView: NSScrollView?
|
|
|
|
|
|
|
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
@@ -413,6 +440,9 @@ final class ViewController: NSViewController {
|
|
|
if hasObservedAppLifecycleForUsage {
|
|
if hasObservedAppLifecycleForUsage {
|
|
|
NotificationCenter.default.removeObserver(self)
|
|
NotificationCenter.default.removeObserver(self)
|
|
|
}
|
|
}
|
|
|
|
|
+ if let observer = schedulePageScrollObservation {
|
|
|
|
|
+ NotificationCenter.default.removeObserver(observer)
|
|
|
|
|
+ }
|
|
|
storeKitStartupTask?.cancel()
|
|
storeKitStartupTask?.cancel()
|
|
|
paywallPurchaseTask?.cancel()
|
|
paywallPurchaseTask?.cancel()
|
|
|
launchPaywallWorkItem?.cancel()
|
|
launchPaywallWorkItem?.cancel()
|
|
@@ -735,6 +765,10 @@ private extension ViewController {
|
|
|
|
|
|
|
|
private func reloadTheme() {
|
|
private func reloadTheme() {
|
|
|
pageCache.removeAll()
|
|
pageCache.removeAll()
|
|
|
|
|
+ if let observer = schedulePageScrollObservation {
|
|
|
|
|
+ NotificationCenter.default.removeObserver(observer)
|
|
|
|
|
+ }
|
|
|
|
|
+ schedulePageScrollObservation = nil
|
|
|
sidebarRowViews.removeAll()
|
|
sidebarRowViews.removeAll()
|
|
|
sidebarPageByView.removeAll()
|
|
sidebarPageByView.removeAll()
|
|
|
zoomJoinModeByView.removeAll()
|
|
zoomJoinModeByView.removeAll()
|
|
@@ -1293,7 +1327,7 @@ private extension ViewController {
|
|
|
case .joinMeetings:
|
|
case .joinMeetings:
|
|
|
built = makeJoinMeetingsContent()
|
|
built = makeJoinMeetingsContent()
|
|
|
case .photo:
|
|
case .photo:
|
|
|
- built = makePlaceholderPage(title: "Schedule", subtitle: "Create and manage your upcoming meetings.")
|
|
|
|
|
|
|
+ built = makeSchedulePageContent()
|
|
|
case .video:
|
|
case .video:
|
|
|
built = makePlaceholderPage(title: "Calendar", subtitle: "View meetings by date and track your plan.")
|
|
built = makePlaceholderPage(title: "Calendar", subtitle: "View meetings by date and track your plan.")
|
|
|
case .settings:
|
|
case .settings:
|
|
@@ -1699,6 +1733,47 @@ private extension ViewController {
|
|
|
return panel
|
|
return panel
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func makeSchedulePageContent() -> NSView {
|
|
|
|
|
+ let panel = NSView()
|
|
|
|
|
+ panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let contentStack = NSStackView()
|
|
|
|
|
+ contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ contentStack.orientation = .vertical
|
|
|
|
|
+ contentStack.spacing = 12
|
|
|
|
|
+ contentStack.alignment = .leading
|
|
|
|
|
+
|
|
|
|
|
+ contentStack.addArrangedSubview(schedulePageHeader())
|
|
|
|
|
+
|
|
|
|
|
+ let heading = textLabel(schedulePageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
|
|
|
|
|
+ schedulePageDateHeadingLabel = heading
|
|
|
|
|
+ contentStack.addArrangedSubview(heading)
|
|
|
|
|
+
|
|
|
|
|
+ let rangeError = textLabel("", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .systemRed)
|
|
|
|
|
+ rangeError.isHidden = true
|
|
|
|
|
+ schedulePageRangeErrorLabel = rangeError
|
|
|
|
|
+ contentStack.addArrangedSubview(rangeError)
|
|
|
|
|
+
|
|
|
|
|
+ let cardsContainer = makeSchedulePageCardsContainer()
|
|
|
|
|
+ contentStack.addArrangedSubview(cardsContainer)
|
|
|
|
|
+
|
|
|
|
|
+ panel.addSubview(contentStack)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
|
|
|
|
|
+ contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
|
|
|
|
|
+ contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 6),
|
|
|
|
|
+ cardsContainer.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor),
|
|
|
|
|
+ cardsContainer.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ Task { [weak self] in
|
|
|
|
|
+ await self?.loadSchedule()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return panel
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
func meetJoinSectionRow() -> NSView {
|
|
func meetJoinSectionRow() -> NSView {
|
|
|
let row = NSStackView()
|
|
let row = NSStackView()
|
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -2580,6 +2655,163 @@ private extension ViewController {
|
|
|
return row
|
|
return row
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func schedulePageHeader() -> NSView {
|
|
|
|
|
+ let container = NSStackView()
|
|
|
|
|
+ container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ container.orientation = .vertical
|
|
|
|
|
+ container.spacing = 10
|
|
|
|
|
+ container.alignment = .leading
|
|
|
|
|
+
|
|
|
|
|
+ let titleLabel = textLabel("Schedule", font: typography.pageTitle, color: palette.textPrimary)
|
|
|
|
|
+ container.addArrangedSubview(titleLabel)
|
|
|
|
|
+
|
|
|
|
|
+ let filterRow = NSStackView()
|
|
|
|
|
+ filterRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ filterRow.orientation = .horizontal
|
|
|
|
|
+ filterRow.alignment = .centerY
|
|
|
|
|
+ filterRow.spacing = 10
|
|
|
|
|
+
|
|
|
|
|
+ let filterDropdown = makeSchedulePageFilterDropdown()
|
|
|
|
|
+ schedulePageFilterDropdown = filterDropdown
|
|
|
|
|
+ filterRow.addArrangedSubview(filterDropdown)
|
|
|
|
|
+
|
|
|
|
|
+ let fromPicker = makeScheduleDatePicker(date: schedulePageFromDate)
|
|
|
|
|
+ schedulePageFromDatePicker = fromPicker
|
|
|
|
|
+ filterRow.addArrangedSubview(fromPicker)
|
|
|
|
|
+
|
|
|
|
|
+ let toPicker = makeScheduleDatePicker(date: schedulePageToDate)
|
|
|
|
|
+ schedulePageToDatePicker = toPicker
|
|
|
|
|
+ filterRow.addArrangedSubview(toPicker)
|
|
|
|
|
+
|
|
|
|
|
+ filterRow.addArrangedSubview(makeSchedulePagePillButton(title: "Apply", action: #selector(schedulePageApplyDateRangePressed(_:))))
|
|
|
|
|
+ filterRow.addArrangedSubview(makeSchedulePagePillButton(title: "Reset", action: #selector(schedulePageResetFiltersPressed(_:))))
|
|
|
|
|
+ filterRow.addArrangedSubview(makeScheduleRefreshButton())
|
|
|
|
|
+
|
|
|
|
|
+ container.addArrangedSubview(filterRow)
|
|
|
|
|
+ container.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
|
|
+ refreshSchedulePageDateFilterUI()
|
|
|
|
|
+ return container
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeSchedulePageFilterDropdown() -> NSPopUpButton {
|
|
|
|
|
+ let button = HoverPopUpButton(frame: .zero, pullsDown: false)
|
|
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ button.autoenablesItems = false
|
|
|
|
|
+ button.isBordered = false
|
|
|
|
|
+ button.bezelStyle = .regularSquare
|
|
|
|
|
+ button.wantsLayer = true
|
|
|
|
|
+ button.layer?.cornerRadius = 8
|
|
|
|
|
+ button.layer?.masksToBounds = true
|
|
|
|
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
|
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
|
|
+ button.font = typography.filterText
|
|
|
|
|
+ button.contentTintColor = palette.textSecondary
|
|
|
|
|
+ button.target = self
|
|
|
|
|
+ button.action = #selector(schedulePageFilterDropdownChanged(_:))
|
|
|
|
|
+ button.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
|
|
+ button.widthAnchor.constraint(equalToConstant: 190).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ button.removeAllItems()
|
|
|
|
|
+ button.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
|
|
|
|
|
+ button.selectItem(at: schedulePageFilter.rawValue)
|
|
|
|
|
+ if let menu = button.menu {
|
|
|
|
|
+ for (index, item) in menu.items.enumerated() {
|
|
|
|
|
+ item.tag = index
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let baseColor = palette.inputBackground
|
|
|
|
|
+ let baseBorder = palette.inputBorder
|
|
|
|
|
+ let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
|
|
|
|
|
+ let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
|
|
|
|
|
+ let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
|
|
|
|
|
+ button.onHoverChanged = { [weak button] hovering in
|
|
|
|
|
+ button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
|
|
+ button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
|
|
|
|
|
+ }
|
|
|
|
|
+ button.onHoverChanged?(false)
|
|
|
|
|
+ return button
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeScheduleDatePicker(date: Date) -> NSDatePicker {
|
|
|
|
|
+ let picker = NSDatePicker()
|
|
|
|
|
+ picker.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ picker.datePickerStyle = .textFieldAndStepper
|
|
|
|
|
+ picker.datePickerElements = [.yearMonthDay]
|
|
|
|
|
+ picker.dateValue = date
|
|
|
|
|
+ picker.font = typography.filterText
|
|
|
|
|
+ picker.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
|
|
+ picker.widthAnchor.constraint(equalToConstant: 156).isActive = true
|
|
|
|
|
+ picker.target = self
|
|
|
|
|
+ picker.action = #selector(schedulePageDatePickerChanged(_:))
|
|
|
|
|
+ return picker
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeSchedulePagePillButton(title: String, action: Selector) -> NSButton {
|
|
|
|
|
+ let button = makeSchedulePillButton(title: title)
|
|
|
|
|
+ button.target = self
|
|
|
|
|
+ button.action = action
|
|
|
|
|
+ button.widthAnchor.constraint(equalToConstant: 92).isActive = true
|
|
|
|
|
+ return button
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeSchedulePageCardsContainer() -> NSView {
|
|
|
|
|
+ if let observer = schedulePageScrollObservation {
|
|
|
|
|
+ NotificationCenter.default.removeObserver(observer)
|
|
|
|
|
+ }
|
|
|
|
|
+ schedulePageScrollObservation = nil
|
|
|
|
|
+
|
|
|
|
|
+ let wrapper = NSView()
|
|
|
|
|
+ wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let scroll = NSScrollView()
|
|
|
|
|
+ scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ scroll.drawsBackground = false
|
|
|
|
|
+ scroll.hasHorizontalScroller = false
|
|
|
|
|
+ scroll.hasVerticalScroller = true
|
|
|
|
|
+ scroll.autohidesScrollers = true
|
|
|
|
|
+ scroll.borderType = .noBorder
|
|
|
|
|
+ let viewportWidth = (schedulePageCardWidth * CGFloat(schedulePageCardsPerRow)) + (schedulePageCardSpacing * CGFloat(schedulePageCardsPerRow - 1))
|
|
|
|
|
+ scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
|
|
|
|
|
+ schedulePageCardsScrollView = scroll
|
|
|
|
|
+ wrapper.addSubview(scroll)
|
|
|
|
|
+
|
|
|
|
|
+ let stack = NSStackView()
|
|
|
|
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ stack.orientation = .vertical
|
|
|
|
|
+ stack.spacing = 12
|
|
|
|
|
+ stack.alignment = .leading
|
|
|
|
|
+ schedulePageCardsStack = stack
|
|
|
|
|
+ scroll.documentView = stack
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ scroll.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
|
|
|
|
|
+ scroll.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
|
|
|
|
|
+ scroll.topAnchor.constraint(equalTo: wrapper.topAnchor),
|
|
|
|
|
+ scroll.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
|
|
|
|
|
+ scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420),
|
|
|
|
|
+
|
|
|
|
|
+ stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
|
|
|
|
+ stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
|
|
|
|
|
+ stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
|
|
|
|
|
+ stack.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
|
|
|
|
|
+ stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ scroll.contentView.postsBoundsChangedNotifications = true
|
|
|
|
|
+ schedulePageScrollObservation = NotificationCenter.default.addObserver(
|
|
|
|
|
+ forName: NSView.boundsDidChangeNotification,
|
|
|
|
|
+ object: scroll.contentView,
|
|
|
|
|
+ queue: .main
|
|
|
|
|
+ ) { [weak self] _ in
|
|
|
|
|
+ self?.schedulePageScrolled()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ renderSchedulePageCards()
|
|
|
|
|
+ return wrapper
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func scheduleTopAuthRow() -> NSView {
|
|
private func scheduleTopAuthRow() -> NSView {
|
|
|
let row = NSStackView()
|
|
let row = NSStackView()
|
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -3960,6 +4192,10 @@ private extension ViewController {
|
|
|
googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
|
|
googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func schedulePageInitialHeadingText() -> String {
|
|
|
|
|
+ googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading schedule…"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
|
|
@objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
|
|
|
guard let selectedItem = sender.selectedItem,
|
|
guard let selectedItem = sender.selectedItem,
|
|
|
let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
|
|
let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
|
|
@@ -3974,6 +4210,38 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @objc func schedulePageFilterDropdownChanged(_ sender: NSPopUpButton) {
|
|
|
|
|
+ guard let selectedItem = sender.selectedItem,
|
|
|
|
|
+ let filter = SchedulePageFilter(rawValue: selectedItem.tag) else { return }
|
|
|
|
|
+ schedulePageFilter = filter
|
|
|
|
|
+ refreshSchedulePageDateFilterUI()
|
|
|
|
|
+ applySchedulePageFiltersAndRender()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc func schedulePageDatePickerChanged(_ sender: NSDatePicker) {
|
|
|
|
|
+ schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate
|
|
|
|
|
+ schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc func schedulePageApplyDateRangePressed(_ sender: NSButton) {
|
|
|
|
|
+ schedulePageFilter = .customRange
|
|
|
|
|
+ schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.customRange.rawValue)
|
|
|
|
|
+ refreshSchedulePageDateFilterUI()
|
|
|
|
|
+ applySchedulePageFiltersAndRender()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc func schedulePageResetFiltersPressed(_ sender: NSButton) {
|
|
|
|
|
+ schedulePageFilter = .all
|
|
|
|
|
+ schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.all.rawValue)
|
|
|
|
|
+ let today = Calendar.current.startOfDay(for: Date())
|
|
|
|
|
+ schedulePageFromDate = today
|
|
|
|
|
+ schedulePageToDate = today
|
|
|
|
|
+ schedulePageFromDatePicker?.dateValue = today
|
|
|
|
|
+ schedulePageToDatePicker?.dateValue = today
|
|
|
|
|
+ refreshSchedulePageDateFilterUI()
|
|
|
|
|
+ applySchedulePageFiltersAndRender()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
|
|
private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
|
|
|
if meeting.isAllDay { return "All day" }
|
|
if meeting.isAllDay { return "All day" }
|
|
|
let f = DateFormatter()
|
|
let f = DateFormatter()
|
|
@@ -4073,6 +4341,148 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func filteredMeetingsForSchedulePage(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ switch schedulePageFilter {
|
|
|
|
|
+ case .all:
|
|
|
|
|
+ return meetings
|
|
|
|
|
+ case .today:
|
|
|
|
|
+ let start = calendar.startOfDay(for: Date())
|
|
|
|
|
+ let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
|
|
|
|
|
+ return meetings.filter { $0.startDate >= start && $0.startDate < end }
|
|
|
|
|
+ case .week:
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ let end = calendar.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
|
|
|
|
|
+ return meetings.filter { $0.startDate >= now && $0.startDate <= end }
|
|
|
|
|
+ case .month:
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ let end = calendar.date(byAdding: .month, value: 1, to: now) ?? now.addingTimeInterval(30 * 86400)
|
|
|
|
|
+ return meetings.filter { $0.startDate >= now && $0.startDate <= end }
|
|
|
|
|
+ case .customRange:
|
|
|
|
|
+ let start = calendar.startOfDay(for: schedulePageFromDate)
|
|
|
|
|
+ let inclusiveEndDay = calendar.startOfDay(for: schedulePageToDate)
|
|
|
|
|
+ guard let end = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else {
|
|
|
|
|
+ return meetings
|
|
|
|
|
+ }
|
|
|
|
|
+ return meetings.filter { $0.startDate >= start && $0.startDate < end }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func refreshSchedulePageDateFilterUI() {
|
|
|
|
|
+ let isCustom = schedulePageFilter == .customRange
|
|
|
|
|
+ schedulePageFromDatePicker?.isEnabled = isCustom
|
|
|
|
|
+ schedulePageToDatePicker?.isEnabled = isCustom
|
|
|
|
|
+ schedulePageFromDatePicker?.alphaValue = isCustom ? 1.0 : 0.65
|
|
|
|
|
+ schedulePageToDatePicker?.alphaValue = isCustom ? 1.0 : 0.65
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func schedulePageHasValidCustomRange() -> Bool {
|
|
|
|
|
+ let start = Calendar.current.startOfDay(for: schedulePageFromDate)
|
|
|
|
|
+ let end = Calendar.current.startOfDay(for: schedulePageToDate)
|
|
|
|
|
+ return start <= end
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func setSchedulePageRangeError(_ message: String?) {
|
|
|
|
|
+ guard let label = schedulePageRangeErrorLabel else { return }
|
|
|
|
|
+ label.stringValue = message ?? ""
|
|
|
|
|
+ label.isHidden = message == nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applySchedulePageFiltersAndRender() {
|
|
|
|
|
+ schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate
|
|
|
|
|
+ schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
|
|
|
|
|
+ if schedulePageFilter == .customRange && !schedulePageHasValidCustomRange() {
|
|
|
|
|
+ setSchedulePageRangeError("Start date must be on or before end date.")
|
|
|
|
|
+ schedulePageFilteredMeetings = []
|
|
|
|
|
+ schedulePageVisibleCount = 0
|
|
|
|
|
+ renderSchedulePageCards()
|
|
|
|
|
+ schedulePageDateHeadingLabel?.stringValue = "Invalid custom date range"
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setSchedulePageRangeError(nil)
|
|
|
|
|
+ schedulePageFilteredMeetings = filteredMeetingsForSchedulePage(scheduleCachedMeetings)
|
|
|
|
|
+ schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredMeetings.count)
|
|
|
|
|
+ renderSchedulePageCards()
|
|
|
|
|
+ schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredMeetings)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func appendSchedulePageBatchIfNeeded() {
|
|
|
|
|
+ guard schedulePageVisibleCount < schedulePageFilteredMeetings.count else { return }
|
|
|
|
|
+ let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredMeetings.count)
|
|
|
|
|
+ guard nextCount > schedulePageVisibleCount else { return }
|
|
|
|
|
+ schedulePageVisibleCount = nextCount
|
|
|
|
|
+ renderSchedulePageCards()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func schedulePageScrolled() {
|
|
|
|
|
+ guard let scroll = schedulePageCardsScrollView else { return }
|
|
|
|
|
+ let contentBounds = scroll.contentView.bounds
|
|
|
|
|
+ let contentHeight = scroll.documentView?.bounds.height ?? 0
|
|
|
|
|
+ guard contentHeight > 0 else { return }
|
|
|
|
|
+ let remaining = contentHeight - (contentBounds.origin.y + contentBounds.height)
|
|
|
|
|
+ if remaining < 180 {
|
|
|
|
|
+ appendSchedulePageBatchIfNeeded()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func renderSchedulePageCards() {
|
|
|
|
|
+ guard let stack = schedulePageCardsStack else { return }
|
|
|
|
|
+ stack.arrangedSubviews.forEach { v in
|
|
|
|
|
+ stack.removeArrangedSubview(v)
|
|
|
|
|
+ v.removeFromSuperview()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let visibleMeetings = Array(schedulePageFilteredMeetings.prefix(schedulePageVisibleCount))
|
|
|
|
|
+ if visibleMeetings.isEmpty {
|
|
|
|
|
+ let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
|
|
|
+ empty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ empty.heightAnchor.constraint(equalToConstant: 120).isActive = true
|
|
|
|
|
+ let viewportWidth = (schedulePageCardWidth * CGFloat(schedulePageCardsPerRow)) + (schedulePageCardSpacing * CGFloat(schedulePageCardsPerRow - 1))
|
|
|
|
|
+ empty.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
|
|
|
|
|
+ styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
+ let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
|
|
|
|
|
+ label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ empty.addSubview(label)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
|
|
|
|
|
+ label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+ stack.addArrangedSubview(empty)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var index = 0
|
|
|
|
|
+ while index < visibleMeetings.count {
|
|
|
|
|
+ let row = NSStackView()
|
|
|
|
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ row.orientation = .horizontal
|
|
|
|
|
+ row.alignment = .top
|
|
|
|
|
+ row.spacing = schedulePageCardSpacing
|
|
|
|
|
+ row.distribution = .fill
|
|
|
|
|
+ let rowEnd = min(index + schedulePageCardsPerRow, visibleMeetings.count)
|
|
|
|
|
+ for meeting in visibleMeetings[index..<rowEnd] {
|
|
|
|
|
+ row.addArrangedSubview(scheduleCard(meeting: meeting))
|
|
|
|
|
+ }
|
|
|
|
|
+ if rowEnd - index < schedulePageCardsPerRow {
|
|
|
|
|
+ for _ in 0..<(schedulePageCardsPerRow - (rowEnd - index)) {
|
|
|
|
|
+ let spacer = NSView()
|
|
|
|
|
+ spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ spacer.widthAnchor.constraint(equalToConstant: schedulePageCardWidth).isActive = true
|
|
|
|
|
+ spacer.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
|
|
+ row.addArrangedSubview(spacer)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ stack.addArrangedSubview(row)
|
|
|
|
|
+ index = rowEnd
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if schedulePageVisibleCount < schedulePageFilteredMeetings.count {
|
|
|
|
|
+ let moreLabel = textLabel("Scroll to load more (\(schedulePageVisibleCount)/\(schedulePageFilteredMeetings.count))", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textMuted)
|
|
|
|
|
+ stack.addArrangedSubview(moreLabel)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func scrollScheduleCards(direction: Int) {
|
|
private func scrollScheduleCards(direction: Int) {
|
|
|
guard let scroll = scheduleCardsScrollView else { return }
|
|
guard let scroll = scheduleCardsScrollView else { return }
|
|
|
let contentBounds = scroll.contentView.bounds
|
|
let contentBounds = scroll.contentView.bounds
|
|
@@ -4091,9 +4501,12 @@ private extension ViewController {
|
|
|
updateGoogleAuthButtonTitle()
|
|
updateGoogleAuthButtonTitle()
|
|
|
applyGoogleProfile(nil)
|
|
applyGoogleProfile(nil)
|
|
|
scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
|
|
+ schedulePageDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
if let stack = scheduleCardsStack {
|
|
if let stack = scheduleCardsStack {
|
|
|
renderScheduleCards(into: stack, meetings: [])
|
|
renderScheduleCards(into: stack, meetings: [])
|
|
|
}
|
|
}
|
|
|
|
|
+ scheduleCachedMeetings = []
|
|
|
|
|
+ applySchedulePageFiltersAndRender()
|
|
|
}
|
|
}
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -4110,6 +4523,8 @@ private extension ViewController {
|
|
|
if let stack = scheduleCardsStack {
|
|
if let stack = scheduleCardsStack {
|
|
|
renderScheduleCards(into: stack, meetings: filtered)
|
|
renderScheduleCards(into: stack, meetings: filtered)
|
|
|
}
|
|
}
|
|
|
|
|
+ scheduleCachedMeetings = meetings
|
|
|
|
|
+ applySchedulePageFiltersAndRender()
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
@@ -4118,9 +4533,12 @@ private extension ViewController {
|
|
|
applyGoogleProfile(nil)
|
|
applyGoogleProfile(nil)
|
|
|
}
|
|
}
|
|
|
scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
|
|
|
+ schedulePageDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
|
if let stack = scheduleCardsStack {
|
|
if let stack = scheduleCardsStack {
|
|
|
renderScheduleCards(into: stack, meetings: [])
|
|
renderScheduleCards(into: stack, meetings: [])
|
|
|
}
|
|
}
|
|
|
|
|
+ scheduleCachedMeetings = []
|
|
|
|
|
+ applySchedulePageFiltersAndRender()
|
|
|
showSimpleError("Couldn’t load schedule.", error: error)
|
|
showSimpleError("Couldn’t load schedule.", error: error)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -4143,7 +4561,8 @@ private extension ViewController {
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
|
self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
|
|
self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
|
|
|
self.pageCache[.joinMeetings] = nil
|
|
self.pageCache[.joinMeetings] = nil
|
|
|
- self.showSidebarPage(.joinMeetings)
|
|
|
|
|
|
|
+ self.pageCache[.photo] = nil
|
|
|
|
|
+ self.showSidebarPage(self.selectedSidebarPage)
|
|
|
}
|
|
}
|
|
|
await self.loadSchedule()
|
|
await self.loadSchedule()
|
|
|
} catch {
|
|
} catch {
|
|
@@ -4170,7 +4589,8 @@ private extension ViewController {
|
|
|
self.updateGoogleAuthButtonTitle()
|
|
self.updateGoogleAuthButtonTitle()
|
|
|
self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
|
self.pageCache[.joinMeetings] = nil
|
|
self.pageCache[.joinMeetings] = nil
|
|
|
- self.showSidebarPage(.joinMeetings)
|
|
|
|
|
|
|
+ self.pageCache[.photo] = nil
|
|
|
|
|
+ self.showSidebarPage(self.selectedSidebarPage)
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
self.showSimpleError("Couldn’t connect Google account.", error: error)
|
|
self.showSimpleError("Couldn’t connect Google account.", error: error)
|