|
|
@@ -218,6 +218,16 @@ class ViewController: NSViewController {
|
|
|
private weak var schedulerMeetingsListStack: NSStackView?
|
|
|
private weak var schedulerMeetingsEmptyLabel: NSTextField?
|
|
|
private weak var schedulerTodayButton: NSButton?
|
|
|
+ private weak var meetingsPageRootView: NSView?
|
|
|
+ private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
|
+ private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
|
+ private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
|
+ private weak var meetingsPageRefreshButton: NSButton?
|
|
|
+ private weak var meetingsPageCountLabel: NSTextField?
|
|
|
+ private weak var meetingsPageStatusLabel: NSTextField?
|
|
|
+ private weak var meetingsPageListStack: NSStackView?
|
|
|
+ private weak var meetingsPageEmptyLabel: NSTextField?
|
|
|
+ private weak var meetingsPageLoadMoreButton: NSButton?
|
|
|
private var schedulerDayButtons: [Date: NSButton] = [:]
|
|
|
private var schedulerDateByButtonTag: [Int: Date] = [:]
|
|
|
private var nextSchedulerButtonTag: Int = 1
|
|
|
@@ -279,6 +289,12 @@ class ViewController: NSViewController {
|
|
|
private let meetingsRefreshInterval: TimeInterval = 60
|
|
|
private let scrollRefreshCooldown: TimeInterval = 3
|
|
|
private var meetingsRateLimitedUntil: Date?
|
|
|
+ private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
|
|
|
+ private var meetingsPageSelectedRangePreset: String = "All"
|
|
|
+ private var meetingsPageFromDateFilter: Date?
|
|
|
+ private var meetingsPageToDateFilter: Date?
|
|
|
+ private var meetingsPageVisibleCount: Int = 0
|
|
|
+ private let meetingsPageSize: Int = 8
|
|
|
|
|
|
private enum SidebarStyle {
|
|
|
case login
|
|
|
@@ -485,6 +501,21 @@ class ViewController: NSViewController {
|
|
|
removeSearchShortcutMonitor()
|
|
|
homeSearchField = nil
|
|
|
homeSearchPill = nil
|
|
|
+ meetingsPageRootView = nil
|
|
|
+ meetingsPageCategoryFilter = nil
|
|
|
+ meetingsPageFromDatePicker = nil
|
|
|
+ meetingsPageToDatePicker = nil
|
|
|
+ meetingsPageRefreshButton = nil
|
|
|
+ meetingsPageCountLabel = nil
|
|
|
+ meetingsPageStatusLabel = nil
|
|
|
+ meetingsPageListStack = nil
|
|
|
+ meetingsPageEmptyLabel = nil
|
|
|
+ meetingsPageLoadMoreButton = nil
|
|
|
+ meetingsPageFilteredMeetings = []
|
|
|
+ meetingsPageSelectedRangePreset = "All"
|
|
|
+ meetingsPageFromDateFilter = nil
|
|
|
+ meetingsPageToDateFilter = nil
|
|
|
+ meetingsPageVisibleCount = 0
|
|
|
allScheduledMeetings = []
|
|
|
homeView?.removeFromSuperview()
|
|
|
homeView = nil
|
|
|
@@ -507,6 +538,7 @@ class ViewController: NSViewController {
|
|
|
removeSearchShortcutMonitor()
|
|
|
homeSearchField = nil
|
|
|
homeSearchPill = nil
|
|
|
+ meetingsPageRootView = nil
|
|
|
homeView?.removeFromSuperview()
|
|
|
selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
|
|
|
homeView = makeHomeView(profile: profile)
|
|
|
@@ -1360,6 +1392,10 @@ class ViewController: NSViewController {
|
|
|
@MainActor
|
|
|
private func setMeetingsLoadingUI(_ loading: Bool) {
|
|
|
refreshMeetingsButton?.isEnabled = loading == false
|
|
|
+ meetingsPageRefreshButton?.isEnabled = loading == false
|
|
|
+ if loading {
|
|
|
+ meetingsPageStatusLabel?.stringValue = "Refreshing meetings..."
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
@MainActor
|
|
|
@@ -1557,6 +1593,9 @@ class ViewController: NSViewController {
|
|
|
let end: Date?
|
|
|
let host: String
|
|
|
let source: String
|
|
|
+ let meetingID: String?
|
|
|
+ let typeLabel: String
|
|
|
+ let statusLabel: String
|
|
|
let webURL: URL?
|
|
|
}
|
|
|
|
|
|
@@ -1564,6 +1603,7 @@ class ViewController: NSViewController {
|
|
|
private func applyMeetings(_ meetings: [ScheduledMeeting]) {
|
|
|
allScheduledMeetings = meetings
|
|
|
applyFilteredMeetings()
|
|
|
+ applyMeetingsPageFilters(resetVisible: true)
|
|
|
}
|
|
|
|
|
|
@MainActor
|
|
|
@@ -1621,6 +1661,137 @@ class ViewController: NSViewController {
|
|
|
renderSchedulerCalendarGrid()
|
|
|
}
|
|
|
|
|
|
+ @MainActor
|
|
|
+ private func applyMeetingsPageFilters(resetVisible: Bool) {
|
|
|
+ let calendar = Calendar.current
|
|
|
+ var filtered = allScheduledMeetings.filter { meeting in
|
|
|
+ switch meetingsPageSelectedRangePreset {
|
|
|
+ case "Today":
|
|
|
+ return calendar.isDate(meeting.start, inSameDayAs: Date())
|
|
|
+ case "This week":
|
|
|
+ return calendar.isDate(meeting.start, equalTo: Date(), toGranularity: .weekOfYear)
|
|
|
+ case "This month":
|
|
|
+ return calendar.isDate(meeting.start, equalTo: Date(), toGranularity: .month)
|
|
|
+ case "Custom range":
|
|
|
+ if let fromDate = meetingsPageFromDateFilter {
|
|
|
+ let startOfFrom = calendar.startOfDay(for: fromDate)
|
|
|
+ if meeting.start < startOfFrom { return false }
|
|
|
+ }
|
|
|
+ if let toDate = meetingsPageToDateFilter {
|
|
|
+ let startOfTo = calendar.startOfDay(for: toDate)
|
|
|
+ let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
|
|
|
+ if meeting.start >= endOfTo { return false }
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ default:
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ filtered.sort { $0.start < $1.start }
|
|
|
+
|
|
|
+ meetingsPageFilteredMeetings = filtered
|
|
|
+ if resetVisible {
|
|
|
+ meetingsPageVisibleCount = min(meetingsPageSize, filtered.count)
|
|
|
+ } else if filtered.count < meetingsPageVisibleCount {
|
|
|
+ meetingsPageVisibleCount = filtered.count
|
|
|
+ }
|
|
|
+ renderMeetingsPageList()
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func renderMeetingsPageList() {
|
|
|
+ guard let stack = meetingsPageListStack else { return }
|
|
|
+ stack.arrangedSubviews.forEach { view in
|
|
|
+ stack.removeArrangedSubview(view)
|
|
|
+ view.removeFromSuperview()
|
|
|
+ }
|
|
|
+
|
|
|
+ let totalCount = meetingsPageFilteredMeetings.count
|
|
|
+ let visibleCount = min(meetingsPageVisibleCount, totalCount)
|
|
|
+ meetingsPageCountLabel?.stringValue = "\(totalCount) scheduled"
|
|
|
+
|
|
|
+ if totalCount == 0 {
|
|
|
+ meetingsPageStatusLabel?.stringValue = "Showing 0 meetings"
|
|
|
+ meetingsPageEmptyLabel?.isHidden = false
|
|
|
+ meetingsPageLoadMoreButton?.isHidden = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ meetingsPageEmptyLabel?.isHidden = true
|
|
|
+ meetingsPageStatusLabel?.stringValue = "Showing \(visibleCount) of \(totalCount) meetings"
|
|
|
+ for meeting in meetingsPageFilteredMeetings.prefix(visibleCount) {
|
|
|
+ let card = makeMeetingRowCard(meeting)
|
|
|
+ stack.addArrangedSubview(card)
|
|
|
+ if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
|
|
|
+ card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let hasMore = visibleCount < totalCount
|
|
|
+ meetingsPageLoadMoreButton?.isHidden = hasMore == false
|
|
|
+ meetingsPageLoadMoreButton?.title = hasMore ? "Load more" : "All meetings loaded"
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func meetingsPageApplyFiltersTapped() {
|
|
|
+ let selectedPreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? "All"
|
|
|
+ meetingsPageSelectedRangePreset = selectedPreset
|
|
|
+ updateMeetingsDatePickersEnabledState()
|
|
|
+
|
|
|
+ if selectedPreset == "Custom range",
|
|
|
+ let fromPicker = meetingsPageFromDatePicker, let toPicker = meetingsPageToDatePicker {
|
|
|
+ let fromDay = Calendar.current.startOfDay(for: fromPicker.dateValue)
|
|
|
+ let toDay = Calendar.current.startOfDay(for: toPicker.dateValue)
|
|
|
+ if fromDay <= toDay {
|
|
|
+ meetingsPageFromDateFilter = fromDay
|
|
|
+ meetingsPageToDateFilter = toDay
|
|
|
+ } else {
|
|
|
+ meetingsPageFromDateFilter = toDay
|
|
|
+ meetingsPageToDateFilter = fromDay
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ meetingsPageFromDateFilter = nil
|
|
|
+ meetingsPageToDateFilter = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ Task { @MainActor in
|
|
|
+ self.applyMeetingsPageFilters(resetVisible: true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func meetingsPageResetFiltersTapped() {
|
|
|
+ meetingsPageSelectedRangePreset = "All"
|
|
|
+ meetingsPageFromDateFilter = nil
|
|
|
+ meetingsPageToDateFilter = nil
|
|
|
+ meetingsPageCategoryFilter?.selectItem(withTitle: "All")
|
|
|
+ meetingsPageFromDatePicker?.dateValue = Date()
|
|
|
+ meetingsPageToDatePicker?.dateValue = Date()
|
|
|
+ updateMeetingsDatePickersEnabledState()
|
|
|
+ Task { @MainActor in
|
|
|
+ self.applyMeetingsPageFilters(resetVisible: true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
|
|
|
+ meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
|
|
|
+ updateMeetingsDatePickersEnabledState()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func updateMeetingsDatePickersEnabledState() {
|
|
|
+ let isCustomRange = meetingsPageSelectedRangePreset == "Custom range"
|
|
|
+ meetingsPageFromDatePicker?.isEnabled = isCustomRange
|
|
|
+ meetingsPageToDatePicker?.isEnabled = isCustomRange
|
|
|
+ meetingsPageFromDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
|
|
|
+ meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func meetingsPageLoadMoreTapped() {
|
|
|
+ Task { @MainActor in
|
|
|
+ let nextCount = meetingsPageVisibleCount + meetingsPageSize
|
|
|
+ meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
|
|
|
+ self.renderMeetingsPageList()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
|
|
|
let calendar = Calendar.current
|
|
|
let safeDayStart = calendar.startOfDay(for: dayStart)
|
|
|
@@ -1789,7 +1960,7 @@ class ViewController: NSViewController {
|
|
|
isToday: Bool,
|
|
|
hasMeetings: Bool
|
|
|
) -> NSButton {
|
|
|
- let button = NSButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
|
|
|
+ let button = HoverButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
|
|
|
button.isBordered = false
|
|
|
button.bezelStyle = .shadowlessSquare
|
|
|
button.focusRingType = .none
|
|
|
@@ -1798,9 +1969,33 @@ class ViewController: NSViewController {
|
|
|
button.wantsLayer = true
|
|
|
button.layer?.cornerRadius = 16
|
|
|
button.layer?.borderWidth = selected ? 1.5 : (hasMeetings ? 1 : 0)
|
|
|
- button.layer?.backgroundColor = selected
|
|
|
- ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16).cgColor
|
|
|
- : NSColor.clear.cgColor
|
|
|
+ let normalBackground = selected
|
|
|
+ ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16)
|
|
|
+ : NSColor.clear
|
|
|
+ let hoverBackground = selected
|
|
|
+ ? accentBlue.withAlphaComponent(palette.isDarkMode ? 1.0 : 0.24)
|
|
|
+ : (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06))
|
|
|
+ button.normalColor = normalBackground
|
|
|
+ button.hoverColor = hoverBackground
|
|
|
+ button.onHoverChanged = { [weak button] hovering in
|
|
|
+ guard let button, selected == false else { return }
|
|
|
+ if hovering {
|
|
|
+ button.layer?.borderWidth = max(button.layer?.borderWidth ?? 0, 1)
|
|
|
+ button.layer?.borderColor = (hasMeetings
|
|
|
+ ? self.accentBlue.withAlphaComponent(0.65)
|
|
|
+ : self.accentBlue.withAlphaComponent(0.45)
|
|
|
+ ).cgColor
|
|
|
+ } else if isToday {
|
|
|
+ button.layer?.borderColor = self.accentOrange.withAlphaComponent(0.8).cgColor
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
+ } else if hasMeetings {
|
|
|
+ button.layer?.borderColor = self.accentBlue.withAlphaComponent(0.45).cgColor
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
+ } else {
|
|
|
+ button.layer?.borderColor = NSColor.clear.cgColor
|
|
|
+ button.layer?.borderWidth = 0
|
|
|
+ }
|
|
|
+ }
|
|
|
if selected {
|
|
|
button.layer?.borderColor = accentBlue.cgColor
|
|
|
} else if isToday {
|
|
|
@@ -1878,20 +2073,25 @@ class ViewController: NSViewController {
|
|
|
self.applyMeetings([])
|
|
|
if case ZoomOAuthError.missingClientId = error {
|
|
|
self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
+ self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
self.promptForZoomOAuthCredentialsIfNeeded()
|
|
|
} else if case ZoomOAuthError.missingClientSecret = error {
|
|
|
self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
+ self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
self.promptForZoomOAuthCredentialsIfNeeded()
|
|
|
} else if case ZoomOAuthError.missingRequiredScope = error {
|
|
|
self.zoomOAuth.clearSavedTokens()
|
|
|
self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
|
|
|
+ self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
|
|
|
} else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
|
|
|
let seconds = max(retryAfterSeconds ?? 300, 30)
|
|
|
self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
|
|
|
let minutes = Int(ceil(Double(seconds) / 60.0))
|
|
|
self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
|
|
|
+ self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
|
|
|
} else {
|
|
|
self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
|
|
|
+ self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -2039,6 +2239,8 @@ class ViewController: NSViewController {
|
|
|
let start_time: String?
|
|
|
let duration: Int?
|
|
|
let host_id: String?
|
|
|
+ let type: Int?
|
|
|
+ let status: String?
|
|
|
let join_url: String?
|
|
|
}
|
|
|
|
|
|
@@ -2096,6 +2298,9 @@ class ViewController: NSViewController {
|
|
|
end: end,
|
|
|
host: meeting.host_id ?? "Zoom Host",
|
|
|
source: "Zoom",
|
|
|
+ meetingID: meeting.id.map(String.init),
|
|
|
+ typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
|
|
|
+ statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
|
|
|
webURL: webURL
|
|
|
)
|
|
|
}
|
|
|
@@ -3362,6 +3567,8 @@ class ViewController: NSViewController {
|
|
|
|
|
|
let settingsView = makeSettingsView()
|
|
|
settingsView.isHidden = selectedHomeSidebarItem != "Settings"
|
|
|
+ let meetingsPageView = makeMeetingsPageView()
|
|
|
+ meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
|
|
|
|
|
|
let schedulerRoot = NSView()
|
|
|
schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
|
|
|
@@ -3485,7 +3692,7 @@ class ViewController: NSViewController {
|
|
|
[searchIcon, searchField, searchHintLabel].forEach {
|
|
|
searchPill.addSubview($0)
|
|
|
}
|
|
|
- [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, schedulerRoot].forEach {
|
|
|
+ [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, schedulerRoot].forEach {
|
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
|
contentColumn.addSubview($0)
|
|
|
}
|
|
|
@@ -3608,6 +3815,11 @@ class ViewController: NSViewController {
|
|
|
settingsView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
|
|
|
settingsView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor),
|
|
|
|
|
|
+ meetingsPageView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
|
|
|
+ meetingsPageView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
|
|
|
+ meetingsPageView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
|
|
|
+ meetingsPageView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor),
|
|
|
+
|
|
|
schedulerRoot.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 8),
|
|
|
schedulerRoot.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor, constant: 8),
|
|
|
schedulerRoot.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor, constant: -8),
|
|
|
@@ -3671,6 +3883,7 @@ class ViewController: NSViewController {
|
|
|
homeMeetingsPanel = panel
|
|
|
homePlaceholderLabel = placeholder
|
|
|
homeSettingsView = settingsView
|
|
|
+ meetingsPageRootView = meetingsPageView
|
|
|
schedulerRootView = schedulerRoot
|
|
|
self.schedulerMonthLabel = schedulerMonthLabel
|
|
|
schedulerCalendarGridStack = schedulerCalendarGrid
|
|
|
@@ -3689,6 +3902,7 @@ class ViewController: NSViewController {
|
|
|
updateClock()
|
|
|
updateMeetingsDayUI()
|
|
|
applyFilteredMeetings()
|
|
|
+ applyMeetingsPageFilters(resetVisible: true)
|
|
|
renderSchedulerCalendarGrid()
|
|
|
updateSchedulerDateHeader()
|
|
|
applySchedulerMeetingsForSelectedDate()
|
|
|
@@ -3709,6 +3923,195 @@ class ViewController: NSViewController {
|
|
|
return root
|
|
|
}
|
|
|
|
|
|
+ private func makeMeetingsPageView() -> NSView {
|
|
|
+ let root = NSView()
|
|
|
+ root.wantsLayer = true
|
|
|
+ root.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
+
|
|
|
+ let container = NSView()
|
|
|
+ container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ container.wantsLayer = true
|
|
|
+ container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
|
|
|
+ container.layer?.cornerRadius = 16
|
|
|
+ container.layer?.borderWidth = 1
|
|
|
+ container.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ root.addSubview(container)
|
|
|
+
|
|
|
+ let title = makeLabel("Meetings", size: 28, color: primaryText, weight: .bold, centered: false)
|
|
|
+ let subtitle = makeLabel("All scheduled Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
|
|
|
+ let count = makeLabel("0 scheduled", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
+ let titleStack = NSStackView(views: [title, subtitle, count])
|
|
|
+ titleStack.orientation = .vertical
|
|
|
+ titleStack.spacing = 4
|
|
|
+ titleStack.alignment = .leading
|
|
|
+ titleStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let categoryFilter = NSPopUpButton()
|
|
|
+ categoryFilter.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ categoryFilter.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
|
|
|
+ categoryFilter.selectItem(withTitle: "All")
|
|
|
+ categoryFilter.target = self
|
|
|
+ categoryFilter.action = #selector(meetingsPageRangePresetChanged(_:))
|
|
|
+
|
|
|
+ let fromDatePicker = NSDatePicker()
|
|
|
+ fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ fromDatePicker.datePickerStyle = .textFieldAndStepper
|
|
|
+ fromDatePicker.datePickerMode = .single
|
|
|
+ fromDatePicker.datePickerElements = [.yearMonthDay]
|
|
|
+ fromDatePicker.dateValue = Date()
|
|
|
+ fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
+
|
|
|
+ let toDatePicker = NSDatePicker()
|
|
|
+ toDatePicker.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ toDatePicker.datePickerStyle = .textFieldAndStepper
|
|
|
+ toDatePicker.datePickerMode = .single
|
|
|
+ toDatePicker.datePickerElements = [.yearMonthDay]
|
|
|
+ toDatePicker.dateValue = Date()
|
|
|
+ toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
+
|
|
|
+ let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(meetingsPageApplyFiltersTapped))
|
|
|
+ applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ applyFiltersButton.isBordered = false
|
|
|
+ applyFiltersButton.wantsLayer = true
|
|
|
+ applyFiltersButton.layer?.backgroundColor = accentBlue.cgColor
|
|
|
+ applyFiltersButton.layer?.cornerRadius = 10
|
|
|
+ applyFiltersButton.contentTintColor = .white
|
|
|
+ applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
+
|
|
|
+ let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(meetingsPageResetFiltersTapped))
|
|
|
+ resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ resetFiltersButton.isBordered = false
|
|
|
+ resetFiltersButton.wantsLayer = true
|
|
|
+ resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
+ resetFiltersButton.layer?.cornerRadius = 10
|
|
|
+ resetFiltersButton.layer?.borderWidth = 1
|
|
|
+ resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ resetFiltersButton.contentTintColor = primaryText
|
|
|
+ resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
+
|
|
|
+ let refreshButton = NSButton(title: "", target: self, action: #selector(refreshMeetingsTapped))
|
|
|
+ refreshButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ refreshButton.isBordered = false
|
|
|
+ refreshButton.wantsLayer = true
|
|
|
+ refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
+ refreshButton.layer?.cornerRadius = 10
|
|
|
+ refreshButton.layer?.borderWidth = 1
|
|
|
+ refreshButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ refreshButton.contentTintColor = primaryText
|
|
|
+ refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
+ if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
|
|
|
+ refreshButton.image = image
|
|
|
+ refreshButton.imagePosition = .imageOnly
|
|
|
+ }
|
|
|
+
|
|
|
+ let filterRow = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
|
|
|
+ filterRow.orientation = .horizontal
|
|
|
+ filterRow.spacing = 10
|
|
|
+ filterRow.alignment = .centerY
|
|
|
+ filterRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let statusLabel = makeLabel("Showing 0 meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
+ statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let listScroll = NSScrollView()
|
|
|
+ listScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ listScroll.drawsBackground = false
|
|
|
+ listScroll.hasVerticalScroller = true
|
|
|
+ listScroll.hasHorizontalScroller = false
|
|
|
+ listScroll.autohidesScrollers = true
|
|
|
+
|
|
|
+ let listDocument = FlippedView()
|
|
|
+ listDocument.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let listStack = NSStackView()
|
|
|
+ listStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ listStack.orientation = .vertical
|
|
|
+ listStack.spacing = 12
|
|
|
+ listStack.alignment = .leading
|
|
|
+ listStack.setContentHuggingPriority(.required, for: .vertical)
|
|
|
+ listStack.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
+ listScroll.documentView = listDocument
|
|
|
+ listDocument.addSubview(listStack)
|
|
|
+
|
|
|
+ let empty = makeLabel("No meetings match the current filters.", size: 14, color: secondaryText, weight: .regular, centered: true)
|
|
|
+ empty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ empty.isHidden = true
|
|
|
+
|
|
|
+ let loadMoreButton = NSButton(title: "Load more", target: self, action: #selector(meetingsPageLoadMoreTapped))
|
|
|
+ loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ loadMoreButton.isBordered = false
|
|
|
+ loadMoreButton.wantsLayer = true
|
|
|
+ loadMoreButton.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
+ loadMoreButton.layer?.cornerRadius = 10
|
|
|
+ loadMoreButton.layer?.borderWidth = 1
|
|
|
+ loadMoreButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ loadMoreButton.contentTintColor = primaryText
|
|
|
+ loadMoreButton.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
+ loadMoreButton.isHidden = true
|
|
|
+
|
|
|
+ [titleStack, filterRow, statusLabel, listScroll, empty, loadMoreButton].forEach { container.addSubview($0) }
|
|
|
+
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
|
|
|
+ container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
|
|
|
+ container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
|
|
|
+ container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
|
|
|
+
|
|
|
+ titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
|
|
|
+ titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
|
|
|
+ titleStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -18),
|
|
|
+
|
|
|
+ filterRow.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
|
|
|
+ filterRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
|
|
|
+ filterRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
|
|
|
+
|
|
|
+ categoryFilter.widthAnchor.constraint(equalToConstant: 170),
|
|
|
+ fromDatePicker.widthAnchor.constraint(equalToConstant: 170),
|
|
|
+ toDatePicker.widthAnchor.constraint(equalToConstant: 170),
|
|
|
+ applyFiltersButton.widthAnchor.constraint(equalToConstant: 96),
|
|
|
+ resetFiltersButton.widthAnchor.constraint(equalToConstant: 96),
|
|
|
+ refreshButton.widthAnchor.constraint(equalToConstant: 44),
|
|
|
+ applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
|
|
|
+ resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
|
|
|
+ refreshButton.heightAnchor.constraint(equalToConstant: 34),
|
|
|
+
|
|
|
+ statusLabel.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 12),
|
|
|
+ statusLabel.leadingAnchor.constraint(equalTo: filterRow.leadingAnchor),
|
|
|
+ statusLabel.trailingAnchor.constraint(equalTo: filterRow.trailingAnchor),
|
|
|
+
|
|
|
+ listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
|
|
|
+ listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
|
|
|
+ listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
|
|
|
+ listScroll.bottomAnchor.constraint(equalTo: loadMoreButton.topAnchor, constant: -12),
|
|
|
+
|
|
|
+ listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
|
|
|
+ listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
|
|
|
+ listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
|
|
|
+ listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
|
|
|
+ // Pin bottom edge so document height grows with arranged subviews.
|
|
|
+ listStack.bottomAnchor.constraint(equalTo: listDocument.bottomAnchor),
|
|
|
+
|
|
|
+ empty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
|
|
|
+ empty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
|
|
|
+
|
|
|
+ loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
|
|
|
+ loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
|
|
|
+ loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
|
|
|
+ loadMoreButton.heightAnchor.constraint(equalToConstant: 40)
|
|
|
+ ])
|
|
|
+
|
|
|
+ meetingsPageCategoryFilter = categoryFilter
|
|
|
+ meetingsPageFromDatePicker = fromDatePicker
|
|
|
+ meetingsPageToDatePicker = toDatePicker
|
|
|
+ meetingsPageRefreshButton = refreshButton
|
|
|
+ meetingsPageCountLabel = count
|
|
|
+ meetingsPageStatusLabel = statusLabel
|
|
|
+ meetingsPageListStack = listStack
|
|
|
+ meetingsPageEmptyLabel = empty
|
|
|
+ meetingsPageLoadMoreButton = loadMoreButton
|
|
|
+ updateMeetingsDatePickersEnabledState()
|
|
|
+ return root
|
|
|
+ }
|
|
|
+
|
|
|
deinit {
|
|
|
removeSearchFieldObserver()
|
|
|
removeSearchShortcutMonitor()
|
|
|
@@ -3969,10 +4372,11 @@ class ViewController: NSViewController {
|
|
|
@MainActor
|
|
|
private func updateSelectedHomeSectionUI() {
|
|
|
let isHome = selectedHomeSidebarItem == "Home"
|
|
|
+ let isMeetings = selectedHomeSidebarItem == "Meetings"
|
|
|
let isSettings = selectedHomeSidebarItem == "Settings"
|
|
|
let isScheduler = selectedHomeSidebarItem == "Scheduler"
|
|
|
homeWelcomeLabel?.stringValue = "Home"
|
|
|
- homeWelcomeLabel?.isHidden = (isHome == false) || isSettings
|
|
|
+ homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
|
|
|
|
|
|
let dashboardViews: [NSView?] = [
|
|
|
homeTimeLabelView,
|
|
|
@@ -3987,7 +4391,7 @@ class ViewController: NSViewController {
|
|
|
meetingsScrollView,
|
|
|
refreshMeetingsButton
|
|
|
]
|
|
|
- let hideDashboard = isHome == false || isSettings
|
|
|
+ let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler
|
|
|
dashboardViews.forEach { $0?.isHidden = hideDashboard }
|
|
|
// Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
|
|
|
// and showed "No meetings…" on top of meeting cards when returning to Home.
|
|
|
@@ -3998,6 +4402,7 @@ class ViewController: NSViewController {
|
|
|
emptyMeetingLabel?.isHidden = hasMeetingCards
|
|
|
}
|
|
|
homeSettingsView?.isHidden = isSettings == false
|
|
|
+ meetingsPageRootView?.isHidden = isMeetings == false
|
|
|
schedulerRootView?.isHidden = isScheduler == false
|
|
|
|
|
|
if isHome {
|
|
|
@@ -4012,6 +4417,9 @@ class ViewController: NSViewController {
|
|
|
applySchedulerMeetingsForSelectedDate()
|
|
|
renderSchedulerCalendarGrid()
|
|
|
}
|
|
|
+ if isMeetings {
|
|
|
+ applyMeetingsPageFilters(resetVisible: false)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
|
|
|
@@ -5020,7 +5428,12 @@ private final class OAuthLoopbackServer {
|
|
|
/// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
|
|
|
private static let loopbackOAuthPort: UInt16 = 8742
|
|
|
|
|
|
- private let queue = DispatchQueue(label: "google.oauth.loopback.server")
|
|
|
+ // Use a high-enough QoS to avoid Thread Performance Checker QoS inversion
|
|
|
+ // warnings when auth is triggered from user-interactive UI actions.
|
|
|
+ private let queue = DispatchQueue(
|
|
|
+ label: "google.oauth.loopback.server",
|
|
|
+ qos: .userInitiated
|
|
|
+ )
|
|
|
private let listener: NWListener
|
|
|
private var readyContinuation: CheckedContinuation<Void, Error>?
|
|
|
private var callbackContinuation: CheckedContinuation<URL, Error>?
|