|
@@ -353,6 +353,16 @@ final class ViewController: NSViewController {
|
|
|
private weak var schedulePageCardsStack: NSStackView?
|
|
private weak var schedulePageCardsStack: NSStackView?
|
|
|
private weak var schedulePageCardsScrollView: NSScrollView?
|
|
private weak var schedulePageCardsScrollView: NSScrollView?
|
|
|
|
|
|
|
|
|
|
+ // MARK: - Calendar page (custom month UI)
|
|
|
|
|
+ private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
|
|
|
|
|
+ private var calendarPageSelectedDate: Date = Calendar.current.startOfDay(for: Date())
|
|
|
|
|
+ private weak var calendarPageMonthLabel: NSTextField?
|
|
|
|
|
+ private weak var calendarPageGridStack: NSStackView?
|
|
|
|
|
+ private var calendarPageGridHeightConstraint: NSLayoutConstraint?
|
|
|
|
|
+ private weak var calendarPageDaySummaryLabel: NSTextField?
|
|
|
|
|
+ private var calendarPageActionPopover: NSPopover?
|
|
|
|
|
+ private var calendarPageCreatePopover: NSPopover?
|
|
|
|
|
+
|
|
|
/// 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
|
|
|
|
|
|
|
@@ -1356,7 +1366,7 @@ private extension ViewController {
|
|
|
case .photo:
|
|
case .photo:
|
|
|
built = makeSchedulePageContent()
|
|
built = makeSchedulePageContent()
|
|
|
case .video:
|
|
case .video:
|
|
|
- built = makePlaceholderPage(title: "Calendar", subtitle: "View meetings by date and track your plan.")
|
|
|
|
|
|
|
+ built = makeCalendarPageContent()
|
|
|
case .settings:
|
|
case .settings:
|
|
|
built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
|
|
built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
|
|
|
}
|
|
}
|
|
@@ -1821,6 +1831,155 @@ private extension ViewController {
|
|
|
return panel
|
|
return panel
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func makeCalendarPageContent() -> NSView {
|
|
|
|
|
+ let panel = NSView()
|
|
|
|
|
+ panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ panel.userInterfaceLayoutDirection = .leftToRight
|
|
|
|
|
+
|
|
|
|
|
+ let contentStack = NSStackView()
|
|
|
|
|
+ contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ contentStack.userInterfaceLayoutDirection = .leftToRight
|
|
|
|
|
+ contentStack.orientation = .vertical
|
|
|
|
|
+ contentStack.spacing = 14
|
|
|
|
|
+ contentStack.alignment = .width
|
|
|
|
|
+ contentStack.distribution = .fill
|
|
|
|
|
+
|
|
|
|
|
+ let titleRow = NSStackView()
|
|
|
|
|
+ titleRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ titleRow.userInterfaceLayoutDirection = .leftToRight
|
|
|
|
|
+ titleRow.orientation = .horizontal
|
|
|
|
|
+ titleRow.alignment = .centerY
|
|
|
|
|
+ titleRow.distribution = .fill
|
|
|
|
|
+ titleRow.spacing = 12
|
|
|
|
|
+
|
|
|
|
|
+ let titleLabel = textLabel("Calendar", font: typography.pageTitle, color: palette.textPrimary)
|
|
|
|
|
+ titleLabel.alignment = .left
|
|
|
|
|
+ titleLabel.maximumNumberOfLines = 1
|
|
|
|
|
+ titleLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ titleLabel.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
|
+ titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ let spacer = NSView()
|
|
|
|
|
+ spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ let prevButton = makeCalendarHeaderPillButton(title: "‹", action: #selector(calendarPrevMonthPressed(_:)))
|
|
|
|
|
+ prevButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
|
|
+ let nextButton = makeCalendarHeaderPillButton(title: "›", action: #selector(calendarNextMonthPressed(_:)))
|
|
|
|
|
+ nextButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ let monthLabel = textLabel("", font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: palette.textSecondary)
|
|
|
|
|
+ monthLabel.alignment = .right
|
|
|
|
|
+ monthLabel.maximumNumberOfLines = 1
|
|
|
|
|
+ monthLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ monthLabel.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
|
|
+ monthLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
+ calendarPageMonthLabel = monthLabel
|
|
|
|
|
+
|
|
|
|
|
+ let refreshButton = HoverButton(title: "", target: self, action: #selector(calendarRefreshPressed(_:)))
|
|
|
|
|
+ refreshButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ refreshButton.isBordered = false
|
|
|
|
|
+ refreshButton.bezelStyle = .regularSquare
|
|
|
|
|
+ refreshButton.wantsLayer = true
|
|
|
|
|
+ refreshButton.layer?.cornerRadius = 15
|
|
|
|
|
+ refreshButton.layer?.masksToBounds = true
|
|
|
|
|
+ refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
|
|
+ refreshButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ refreshButton.layer?.borderWidth = 1
|
|
|
|
|
+ refreshButton.setButtonType(.momentaryChange)
|
|
|
|
|
+ refreshButton.contentTintColor = palette.textSecondary
|
|
|
|
|
+ refreshButton.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Sync calendar")
|
|
|
|
|
+ refreshButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
|
|
|
|
+ refreshButton.imagePosition = .imageOnly
|
|
|
|
|
+ refreshButton.imageScaling = .scaleProportionallyDown
|
|
|
|
|
+ refreshButton.focusRingType = .none
|
|
|
|
|
+ refreshButton.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
|
|
|
|
+ refreshButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ titleRow.addArrangedSubview(titleLabel)
|
|
|
|
|
+ titleRow.addArrangedSubview(spacer)
|
|
|
|
|
+ titleRow.addArrangedSubview(prevButton)
|
|
|
|
|
+ titleRow.addArrangedSubview(nextButton)
|
|
|
|
|
+ titleRow.addArrangedSubview(monthLabel)
|
|
|
|
|
+ titleRow.addArrangedSubview(refreshButton)
|
|
|
|
|
+
|
|
|
|
|
+ let weekdayRow = NSStackView()
|
|
|
|
|
+ weekdayRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ weekdayRow.userInterfaceLayoutDirection = .leftToRight
|
|
|
|
|
+ weekdayRow.orientation = .horizontal
|
|
|
|
|
+ weekdayRow.alignment = .centerY
|
|
|
|
|
+ weekdayRow.distribution = .fillEqually
|
|
|
|
|
+ weekdayRow.spacing = 10
|
|
|
|
|
+
|
|
|
|
|
+ for symbol in calendarWeekdaySymbolsStartingAtFirstWeekday() {
|
|
|
|
|
+ let label = textLabel(symbol, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textMuted)
|
|
|
|
|
+ label.alignment = .center
|
|
|
|
|
+ label.maximumNumberOfLines = 1
|
|
|
|
|
+ label.lineBreakMode = .byClipping
|
|
|
|
|
+ weekdayRow.addArrangedSubview(label)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let gridStack = NSStackView()
|
|
|
|
|
+ gridStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ gridStack.userInterfaceLayoutDirection = .leftToRight
|
|
|
|
|
+ gridStack.orientation = .vertical
|
|
|
|
|
+ gridStack.alignment = .width
|
|
|
|
|
+ gridStack.distribution = .fillEqually
|
|
|
|
|
+ gridStack.spacing = 10
|
|
|
|
|
+ calendarPageGridStack = gridStack
|
|
|
|
|
+
|
|
|
|
|
+ let gridCard = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
|
|
|
|
|
+ gridCard.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ styleSurface(gridCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
+ let gridHeightConstraint = gridCard.heightAnchor.constraint(equalToConstant: 320)
|
|
|
|
|
+ gridHeightConstraint.isActive = true
|
|
|
|
|
+ calendarPageGridHeightConstraint = gridHeightConstraint
|
|
|
|
|
+ gridCard.addSubview(gridStack)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ gridStack.leadingAnchor.constraint(equalTo: gridCard.leadingAnchor, constant: 12),
|
|
|
|
|
+ gridStack.trailingAnchor.constraint(equalTo: gridCard.trailingAnchor, constant: -12),
|
|
|
|
|
+ gridStack.topAnchor.constraint(equalTo: gridCard.topAnchor, constant: 12),
|
|
|
|
|
+ gridStack.bottomAnchor.constraint(equalTo: gridCard.bottomAnchor, constant: -12)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ let daySummary = textLabel("", font: typography.dateHeading, color: palette.textSecondary)
|
|
|
|
|
+ daySummary.alignment = .left
|
|
|
|
|
+ daySummary.maximumNumberOfLines = 2
|
|
|
|
|
+ daySummary.lineBreakMode = .byWordWrapping
|
|
|
|
|
+ calendarPageDaySummaryLabel = daySummary
|
|
|
|
|
+
|
|
|
|
|
+ contentStack.addArrangedSubview(titleRow)
|
|
|
|
|
+ contentStack.addArrangedSubview(weekdayRow)
|
|
|
|
|
+ contentStack.addArrangedSubview(gridCard)
|
|
|
|
|
+ contentStack.addArrangedSubview(daySummary)
|
|
|
|
|
+
|
|
|
|
|
+ panel.addSubview(contentStack)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28),
|
|
|
|
|
+ contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28),
|
|
|
|
|
+ contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
|
|
|
|
|
+ contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -16),
|
|
|
|
|
+ titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
|
|
|
|
|
+ weekdayRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
|
|
|
|
|
+ gridCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
|
|
|
|
|
+ daySummary.widthAnchor.constraint(equalTo: contentStack.widthAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ calendarPageMonthAnchor = calendarStartOfMonth(for: Date())
|
|
|
|
|
+ calendarPageSelectedDate = calendar.startOfDay(for: Date())
|
|
|
|
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
|
|
+ renderCalendarMonthGrid()
|
|
|
|
|
+ renderCalendarSelectedDay()
|
|
|
|
|
+
|
|
|
|
|
+ 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
|
|
@@ -4312,6 +4471,666 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+private let calendarDayKeyFormatter: DateFormatter = {
|
|
|
|
|
+ let f = DateFormatter()
|
|
|
|
|
+ f.calendar = Calendar(identifier: .gregorian)
|
|
|
|
|
+ f.locale = Locale(identifier: "en_US_POSIX")
|
|
|
|
|
+ f.timeZone = TimeZone.current
|
|
|
|
|
+ f.dateFormat = "yyyy-MM-dd"
|
|
|
|
|
+ return f
|
|
|
|
|
+}()
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Calendar page actions + rendering
|
|
|
|
|
+
|
|
|
|
|
+private extension ViewController {
|
|
|
|
|
+ private func makeCalendarHeaderPillButton(title: String, action: Selector) -> NSButton {
|
|
|
|
|
+ let button = makeSchedulePillButton(title: title)
|
|
|
|
|
+ button.target = self
|
|
|
|
|
+ button.action = action
|
|
|
|
|
+ button.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
|
|
|
|
+ return button
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarStartOfMonth(for date: Date) -> Date {
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ let comps = calendar.dateComponents([.year, .month], from: date)
|
|
|
|
|
+ return calendar.date(from: comps) ?? calendar.startOfDay(for: date)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarMonthTitleText(for monthAnchor: Date) -> String {
|
|
|
|
|
+ let f = DateFormatter()
|
|
|
|
|
+ f.locale = Locale.current
|
|
|
|
|
+ f.timeZone = TimeZone.current
|
|
|
|
|
+ f.dateFormat = "MMMM yyyy"
|
|
|
|
|
+ return f.string(from: monthAnchor)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarWeekdaySymbolsStartingAtFirstWeekday() -> [String] {
|
|
|
|
|
+ // Align weekday header to Calendar.current.firstWeekday
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ var symbols = DateFormatter().veryShortWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"]
|
|
|
|
|
+ // veryShortWeekdaySymbols starts with Sunday in most locales; rotate to firstWeekday.
|
|
|
|
|
+ let first = max(1, min(7, calendar.firstWeekday)) // 1..7
|
|
|
|
|
+ let shift = (first - 1) % 7
|
|
|
|
|
+ if shift == 0 { return symbols }
|
|
|
|
|
+ let head = Array(symbols[shift...])
|
|
|
|
|
+ let tail = Array(symbols[..<shift])
|
|
|
|
|
+ symbols = head + tail
|
|
|
|
|
+ return symbols
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc func calendarPrevMonthPressed(_ sender: NSButton) {
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ calendarPageMonthAnchor = calendar.date(byAdding: .month, value: -1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
|
|
|
|
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
|
|
+ renderCalendarMonthGrid()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc func calendarNextMonthPressed(_ sender: NSButton) {
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ calendarPageMonthAnchor = calendar.date(byAdding: .month, value: 1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
|
|
|
|
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
|
|
+ renderCalendarMonthGrid()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc func calendarRefreshPressed(_ sender: NSButton) {
|
|
|
|
|
+ Task { [weak self] in
|
|
|
|
|
+ await self?.loadSchedule()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc func calendarDayCellPressed(_ sender: NSButton) {
|
|
|
|
|
+ guard let raw = sender.identifier?.rawValue,
|
|
|
|
|
+ let date = calendarDayKeyFormatter.date(from: raw) else { return }
|
|
|
|
|
+ calendarPageSelectedDate = Calendar.current.startOfDay(for: date)
|
|
|
|
|
+ renderCalendarMonthGrid()
|
|
|
|
|
+ renderCalendarSelectedDay()
|
|
|
|
|
+ if let refreshedButton = calendarButton(forDateKey: raw) {
|
|
|
|
|
+ showCalendarDayActionPopover(relativeTo: refreshedButton)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func showCalendarDayActionPopover(relativeTo anchor: NSView) {
|
|
|
|
|
+ guard anchor.window != nil else { return }
|
|
|
|
|
+ calendarPageActionPopover?.performClose(nil)
|
|
|
|
|
+ let popover = NSPopover()
|
|
|
|
|
+ popover.behavior = .transient
|
|
|
|
|
+ popover.animates = true
|
|
|
|
|
+ popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
|
|
|
+ popover.contentViewController = CalendarDayActionMenuViewController(
|
|
|
|
|
+ palette: palette,
|
|
|
|
|
+ onSchedule: { [weak self] in
|
|
|
|
|
+ self?.calendarPageActionPopover?.performClose(nil)
|
|
|
|
|
+ self?.calendarPageActionPopover = nil
|
|
|
|
|
+ self?.presentCreateMeetingPopover(relativeTo: anchor)
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ calendarPageActionPopover = popover
|
|
|
|
|
+ popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func presentCreateMeetingPopover(relativeTo anchor: NSView) {
|
|
|
|
|
+ guard anchor.window != nil else { return }
|
|
|
|
|
+ calendarPageCreatePopover?.performClose(nil)
|
|
|
|
|
+ let popover = NSPopover()
|
|
|
|
|
+ popover.behavior = .transient
|
|
|
|
|
+ popover.animates = true
|
|
|
|
|
+ popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
|
|
|
+ let selectedDate = Calendar.current.startOfDay(for: calendarPageSelectedDate)
|
|
|
|
|
+ let vc = CreateMeetingPopoverViewController(
|
|
|
|
|
+ palette: palette,
|
|
|
|
|
+ typography: typography,
|
|
|
|
|
+ selectedDate: selectedDate,
|
|
|
|
|
+ onCancel: { [weak self] in
|
|
|
|
|
+ self?.calendarPageCreatePopover?.performClose(nil)
|
|
|
|
|
+ self?.calendarPageCreatePopover = nil
|
|
|
|
|
+ },
|
|
|
|
|
+ onSave: { [weak self] draft in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ self.calendarPageCreatePopover?.performClose(nil)
|
|
|
|
|
+ self.calendarPageCreatePopover = nil
|
|
|
|
|
+ self.calendarCreateMeeting(title: draft.title, notes: draft.notes, start: draft.startDate, end: draft.endDate)
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ popover.contentViewController = vc
|
|
|
|
|
+ calendarPageCreatePopover = popover
|
|
|
|
|
+ popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarCreateMeeting(title: String, notes: String?, start: Date, end: Date) {
|
|
|
|
|
+ Task { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
|
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
|
|
+ _ = try await self.calendarClient.createEvent(
|
|
|
|
|
+ accessToken: token,
|
|
|
|
|
+ title: title,
|
|
|
|
|
+ description: notes,
|
|
|
|
|
+ start: start,
|
|
|
|
|
+ end: end,
|
|
|
|
|
+ timeZone: .current
|
|
|
|
|
+ )
|
|
|
|
|
+ await self.loadSchedule()
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ self.calendarPageSelectedDate = calendar.startOfDay(for: start)
|
|
|
|
|
+ self.calendarPageMonthAnchor = self.calendarStartOfMonth(for: start)
|
|
|
|
|
+ self.calendarPageMonthLabel?.stringValue = self.calendarMonthTitleText(for: self.calendarPageMonthAnchor)
|
|
|
|
|
+ self.renderCalendarMonthGrid()
|
|
|
|
|
+ self.renderCalendarSelectedDay()
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ self.showSimpleError("Couldn’t create meeting.", error: error)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func renderCalendarMonthGrid() {
|
|
|
|
|
+ guard let gridStack = calendarPageGridStack else { return }
|
|
|
|
|
+ gridStack.arrangedSubviews.forEach { v in
|
|
|
|
|
+ gridStack.removeArrangedSubview(v)
|
|
|
|
|
+ v.removeFromSuperview()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ let monthStart = calendarStartOfMonth(for: calendarPageMonthAnchor)
|
|
|
|
|
+ guard let dayRange = calendar.range(of: .day, in: .month, for: monthStart),
|
|
|
|
|
+ let monthEnd = calendar.date(byAdding: DateComponents(month: 1, day: 0), to: monthStart) else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let firstWeekday = calendar.component(.weekday, from: monthStart) // 1..7
|
|
|
|
|
+ let leadingEmpty = (firstWeekday - calendar.firstWeekday + 7) % 7
|
|
|
|
|
+ let totalDays = dayRange.count
|
|
|
|
|
+ let totalCells = leadingEmpty + totalDays
|
|
|
|
|
+ let rowCount = Int(ceil(Double(totalCells) / 7.0))
|
|
|
|
|
+ let rowHeight: CGFloat = 44
|
|
|
|
|
+ let rowSpacing: CGFloat = 10
|
|
|
|
|
+ let verticalPadding: CGFloat = 24
|
|
|
|
|
+ calendarPageGridHeightConstraint?.constant = verticalPadding + (CGFloat(rowCount) * rowHeight) + (CGFloat(max(0, rowCount - 1)) * rowSpacing)
|
|
|
|
|
+
|
|
|
|
|
+ let meetingCounts = calendarMeetingCountsByDay(from: scheduleCachedMeetings, monthStart: monthStart, monthEnd: monthEnd)
|
|
|
|
|
+
|
|
|
|
|
+ var day = 1
|
|
|
|
|
+ for _ in 0..<rowCount {
|
|
|
|
|
+ let row = NSStackView()
|
|
|
|
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ row.userInterfaceLayoutDirection = .leftToRight
|
|
|
|
|
+ row.orientation = .horizontal
|
|
|
|
|
+ row.alignment = .top
|
|
|
|
|
+ row.distribution = .fillEqually
|
|
|
|
|
+ row.spacing = rowSpacing
|
|
|
|
|
+ row.heightAnchor.constraint(equalToConstant: rowHeight).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ for col in 0..<7 {
|
|
|
|
|
+ let cellIndex = (gridStack.arrangedSubviews.count * 7) + col
|
|
|
|
|
+ if cellIndex < leadingEmpty || day > totalDays {
|
|
|
|
|
+ row.addArrangedSubview(calendarEmptyDayCell())
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ guard let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) else {
|
|
|
|
|
+ row.addArrangedSubview(calendarEmptyDayCell())
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ let isSelected = calendar.isDate(date, inSameDayAs: calendarPageSelectedDate)
|
|
|
|
|
+ let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: date))
|
|
|
|
|
+ let count = meetingCounts[key] ?? 0
|
|
|
|
|
+ row.addArrangedSubview(calendarDayCell(dayNumber: day, dateKey: key, meetingCount: count, isSelected: isSelected))
|
|
|
|
|
+ day += 1
|
|
|
|
|
+ }
|
|
|
|
|
+ gridStack.addArrangedSubview(row)
|
|
|
|
|
+ row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarButton(forDateKey key: String) -> NSButton? {
|
|
|
|
|
+ guard let gridStack = calendarPageGridStack else { return nil }
|
|
|
|
|
+ for rowView in gridStack.arrangedSubviews {
|
|
|
|
|
+ guard let row = rowView as? NSStackView else { continue }
|
|
|
|
|
+ for cell in row.arrangedSubviews {
|
|
|
|
|
+ if let button = cell as? NSButton, button.identifier?.rawValue == key {
|
|
|
|
|
+ return button
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func renderCalendarSelectedDay() {
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ let selectedDay = calendar.startOfDay(for: calendarPageSelectedDate)
|
|
|
|
|
+ let nextDay = calendar.date(byAdding: .day, value: 1, to: selectedDay) ?? selectedDay.addingTimeInterval(86400)
|
|
|
|
|
+
|
|
|
|
|
+ let meetings = scheduleCachedMeetings
|
|
|
|
|
+ .filter { $0.startDate >= selectedDay && $0.startDate < nextDay }
|
|
|
|
|
+ .sorted(by: { $0.startDate < $1.startDate })
|
|
|
|
|
+
|
|
|
|
|
+ let f = DateFormatter()
|
|
|
|
|
+ f.locale = Locale.current
|
|
|
|
|
+ f.timeZone = TimeZone.current
|
|
|
|
|
+ f.dateFormat = "EEE, d MMM"
|
|
|
|
|
+
|
|
|
|
|
+ if meetings.isEmpty {
|
|
|
|
|
+ calendarPageDaySummaryLabel?.stringValue = googleOAuth.loadTokens() == nil
|
|
|
|
|
+ ? "Connect Google to see meetings"
|
|
|
|
|
+ : "No meetings on \(f.string(from: selectedDay))"
|
|
|
|
|
+ } else if meetings.count == 1 {
|
|
|
|
|
+ calendarPageDaySummaryLabel?.stringValue = "1 meeting on \(f.string(from: selectedDay))"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ calendarPageDaySummaryLabel?.stringValue = "\(meetings.count) meetings on \(f.string(from: selectedDay))"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarMeetingCountsByDay(from meetings: [ScheduledMeeting], monthStart: Date, monthEnd: Date) -> [String: Int] {
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ var counts: [String: Int] = [:]
|
|
|
|
|
+ for meeting in meetings {
|
|
|
|
|
+ guard meeting.startDate >= monthStart && meeting.startDate < monthEnd else { continue }
|
|
|
|
|
+ let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: meeting.startDate))
|
|
|
|
|
+ counts[key, default: 0] += 1
|
|
|
|
|
+ }
|
|
|
|
|
+ return counts
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarEmptyDayCell() -> NSView {
|
|
|
|
|
+ let v = NSView()
|
|
|
|
|
+ v.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ v.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
|
|
+ return v
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarDayCell(dayNumber: Int, dateKey: String, meetingCount: Int, isSelected: Bool) -> NSButton {
|
|
|
|
|
+ let button = HoverButton(title: "", target: self, action: #selector(calendarDayCellPressed(_:)))
|
|
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ button.isBordered = false
|
|
|
|
|
+ button.bezelStyle = .regularSquare
|
|
|
|
|
+ button.wantsLayer = true
|
|
|
|
|
+ button.layer?.cornerRadius = 10
|
|
|
|
|
+ button.layer?.masksToBounds = true
|
|
|
|
|
+ button.identifier = NSUserInterfaceItemIdentifier(dateKey)
|
|
|
|
|
+ button.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
|
|
+ button.setContentHuggingPriority(.required, for: .vertical)
|
|
|
|
|
+ button.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
|
+ button.alignment = .left
|
|
|
|
|
+ button.imagePosition = .noImage
|
|
|
|
|
+
|
|
|
|
|
+ let base = palette.inputBackground
|
|
|
|
|
+ let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
|
|
|
|
|
+ let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
|
|
|
|
|
+ let selectedBackground = darkModeEnabled
|
|
|
|
|
+ ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
|
|
|
|
|
+ : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
|
|
|
|
|
+ let borderIdle = palette.inputBorder
|
|
|
|
|
+ let borderSelected = palette.primaryBlueBorder
|
|
|
|
|
+
|
|
|
|
|
+ func applyAppearance(hovering: Bool) {
|
|
|
|
|
+ button.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hover : base)).cgColor
|
|
|
|
|
+ button.layer?.borderWidth = isSelected ? 1.5 : 1
|
|
|
|
|
+ button.layer?.borderColor = (isSelected ? borderSelected : borderIdle).cgColor
|
|
|
|
|
+ }
|
|
|
|
|
+ applyAppearance(hovering: false)
|
|
|
|
|
+ button.onHoverChanged = { hovering in
|
|
|
|
|
+ applyAppearance(hovering: hovering)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ button.attributedTitle = NSAttributedString(
|
|
|
|
|
+ string: " \(dayNumber)",
|
|
|
|
|
+ attributes: [
|
|
|
|
|
+ .font: NSFont.systemFont(ofSize: 14, weight: .bold),
|
|
|
|
|
+ .foregroundColor: palette.textPrimary
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if meetingCount > 0 {
|
|
|
|
|
+ let dot = roundedContainer(cornerRadius: 4, color: palette.meetingBadge)
|
|
|
|
|
+ dot.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ dot.layer?.borderWidth = 0
|
|
|
|
|
+ button.addSubview(dot)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ dot.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -10),
|
|
|
|
|
+ dot.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
|
|
|
|
+ dot.widthAnchor.constraint(equalToConstant: 8),
|
|
|
|
|
+ dot.heightAnchor.constraint(equalToConstant: 8)
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return button
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private final class CalendarDayActionMenuViewController: NSViewController {
|
|
|
|
|
+ private let palette: Palette
|
|
|
|
|
+ private let onSchedule: () -> Void
|
|
|
|
|
+
|
|
|
|
|
+ init(palette: Palette, onSchedule: @escaping () -> Void) {
|
|
|
|
|
+ self.palette = palette
|
|
|
|
|
+ self.onSchedule = onSchedule
|
|
|
|
|
+ super.init(nibName: nil, bundle: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ override func loadView() {
|
|
|
|
|
+ let root = NSView()
|
|
|
|
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let stack = NSStackView()
|
|
|
|
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ stack.orientation = .vertical
|
|
|
|
|
+ stack.alignment = .leading
|
|
|
|
|
+ stack.spacing = 10
|
|
|
|
|
+
|
|
|
|
|
+ let title = NSTextField(labelWithString: "Actions")
|
|
|
|
|
+ title.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
|
|
|
|
|
+ title.textColor = palette.textMuted
|
|
|
|
|
+
|
|
|
|
|
+ let schedule = NSButton(title: "Schedule meeting", target: self, action: #selector(schedulePressed(_:)))
|
|
|
|
|
+ schedule.bezelStyle = .rounded
|
|
|
|
|
+ schedule.font = NSFont.systemFont(ofSize: 13, weight: .medium)
|
|
|
|
|
+
|
|
|
|
|
+ stack.addArrangedSubview(title)
|
|
|
|
|
+ stack.addArrangedSubview(schedule)
|
|
|
|
|
+ root.addSubview(stack)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ root.widthAnchor.constraint(equalToConstant: 220),
|
|
|
|
|
+ root.heightAnchor.constraint(greaterThanOrEqualToConstant: 86),
|
|
|
|
|
+ stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
|
|
|
|
|
+ stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
|
|
|
|
|
+ stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
|
|
|
|
|
+ stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ view = root
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func schedulePressed(_ sender: NSButton) {
|
|
|
|
|
+ onSchedule()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private final class CreateMeetingPopoverViewController: NSViewController {
|
|
|
|
|
+ struct Draft {
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let notes: String?
|
|
|
|
|
+ let startDate: Date
|
|
|
|
|
+ let endDate: Date
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private let palette: Palette
|
|
|
|
|
+ private let typography: Typography
|
|
|
|
|
+ private let selectedDate: Date
|
|
|
|
|
+ private let onCancel: () -> Void
|
|
|
|
|
+ private let onSave: (Draft) -> Void
|
|
|
|
|
+
|
|
|
|
|
+ private var titleField: NSTextField?
|
|
|
|
|
+ private var timePicker: NSDatePicker?
|
|
|
|
|
+ private var durationField: NSTextField?
|
|
|
|
|
+ private var notesView: NSTextView?
|
|
|
|
|
+ private var errorLabel: NSTextField?
|
|
|
|
|
+
|
|
|
|
|
+ init(palette: Palette,
|
|
|
|
|
+ typography: Typography,
|
|
|
|
|
+ selectedDate: Date,
|
|
|
|
|
+ onCancel: @escaping () -> Void,
|
|
|
|
|
+ onSave: @escaping (Draft) -> Void) {
|
|
|
|
|
+ self.palette = palette
|
|
|
|
|
+ self.typography = typography
|
|
|
|
|
+ self.selectedDate = selectedDate
|
|
|
|
|
+ self.onCancel = onCancel
|
|
|
|
|
+ self.onSave = onSave
|
|
|
|
|
+ super.init(nibName: nil, bundle: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ override func loadView() {
|
|
|
|
|
+ let root = NSView()
|
|
|
|
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let stack = NSStackView()
|
|
|
|
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ stack.orientation = .vertical
|
|
|
|
|
+ stack.alignment = .width
|
|
|
|
|
+ stack.spacing = 12
|
|
|
|
|
+
|
|
|
|
|
+ let header = NSTextField(labelWithString: "Schedule meeting")
|
|
|
|
|
+ header.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
|
|
|
|
|
+ header.textColor = palette.textPrimary
|
|
|
|
|
+
|
|
|
|
|
+ let titleLabel = NSTextField(labelWithString: "Title")
|
|
|
|
|
+ titleLabel.font = typography.fieldLabel
|
|
|
|
|
+ titleLabel.textColor = palette.textSecondary
|
|
|
|
|
+
|
|
|
|
|
+ let titleShell = NSView()
|
|
|
|
|
+ titleShell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ titleShell.wantsLayer = true
|
|
|
|
|
+ titleShell.layer?.cornerRadius = 8
|
|
|
|
|
+ titleShell.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
|
|
+ titleShell.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ titleShell.layer?.borderWidth = 1
|
|
|
|
|
+ titleShell.heightAnchor.constraint(equalToConstant: 40).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ let titleField = NSTextField(string: "")
|
|
|
|
|
+ titleField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ titleField.isBordered = false
|
|
|
|
|
+ titleField.drawsBackground = false
|
|
|
|
|
+ titleField.focusRingType = .none
|
|
|
|
|
+ titleField.font = NSFont.systemFont(ofSize: 14, weight: .regular)
|
|
|
|
|
+ titleField.textColor = palette.textPrimary
|
|
|
|
|
+ titleField.placeholderString = "Team sync"
|
|
|
|
|
+ titleShell.addSubview(titleField)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ titleField.leadingAnchor.constraint(equalTo: titleShell.leadingAnchor, constant: 10),
|
|
|
|
|
+ titleField.trailingAnchor.constraint(equalTo: titleShell.trailingAnchor, constant: -10),
|
|
|
|
|
+ titleField.centerYAnchor.constraint(equalTo: titleShell.centerYAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+ self.titleField = titleField
|
|
|
|
|
+
|
|
|
|
|
+ let timeRow = NSStackView()
|
|
|
|
|
+ timeRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ timeRow.orientation = .horizontal
|
|
|
|
|
+ timeRow.alignment = .centerY
|
|
|
|
|
+ timeRow.spacing = 10
|
|
|
|
|
+ timeRow.distribution = .fill
|
|
|
|
|
+
|
|
|
|
|
+ let startLabel = NSTextField(labelWithString: "Start")
|
|
|
|
|
+ startLabel.font = typography.fieldLabel
|
|
|
|
|
+ startLabel.textColor = palette.textSecondary
|
|
|
|
|
+
|
|
|
|
|
+ let pickerShell = NSView()
|
|
|
|
|
+ pickerShell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ pickerShell.wantsLayer = true
|
|
|
|
|
+ pickerShell.layer?.cornerRadius = 8
|
|
|
|
|
+ pickerShell.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
|
|
+ pickerShell.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ pickerShell.layer?.borderWidth = 1
|
|
|
|
|
+ pickerShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ let timePicker = NSDatePicker()
|
|
|
|
|
+ timePicker.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ timePicker.isBordered = false
|
|
|
|
|
+ timePicker.drawsBackground = false
|
|
|
|
|
+ timePicker.focusRingType = .none
|
|
|
|
|
+ timePicker.datePickerStyle = .textFieldAndStepper
|
|
|
|
|
+ timePicker.datePickerElements = [.hourMinute]
|
|
|
|
|
+ timePicker.font = typography.filterText
|
|
|
|
|
+ timePicker.textColor = palette.textSecondary
|
|
|
|
|
+ timePicker.dateValue = Date()
|
|
|
|
|
+ pickerShell.addSubview(timePicker)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ timePicker.leadingAnchor.constraint(equalTo: pickerShell.leadingAnchor, constant: 8),
|
|
|
|
|
+ timePicker.trailingAnchor.constraint(equalTo: pickerShell.trailingAnchor, constant: -8),
|
|
|
|
|
+ timePicker.centerYAnchor.constraint(equalTo: pickerShell.centerYAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+ self.timePicker = timePicker
|
|
|
|
|
+
|
|
|
|
|
+ let durationLabel = NSTextField(labelWithString: "Duration (min)")
|
|
|
|
|
+ durationLabel.font = typography.fieldLabel
|
|
|
|
|
+ durationLabel.textColor = palette.textSecondary
|
|
|
|
|
+
|
|
|
|
|
+ let durationShell = NSView()
|
|
|
|
|
+ durationShell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ durationShell.wantsLayer = true
|
|
|
|
|
+ durationShell.layer?.cornerRadius = 8
|
|
|
|
|
+ durationShell.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
|
|
+ durationShell.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ durationShell.layer?.borderWidth = 1
|
|
|
|
|
+ durationShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ let durationField = NSTextField(string: "30")
|
|
|
|
|
+ durationField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ durationField.isBordered = false
|
|
|
|
|
+ durationField.drawsBackground = false
|
|
|
|
|
+ durationField.focusRingType = .none
|
|
|
|
|
+ durationField.font = typography.filterText
|
|
|
|
|
+ durationField.textColor = palette.textSecondary
|
|
|
|
|
+ durationField.formatter = NumberFormatter()
|
|
|
|
|
+ durationShell.addSubview(durationField)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ durationField.leadingAnchor.constraint(equalTo: durationShell.leadingAnchor, constant: 8),
|
|
|
|
|
+ durationField.trailingAnchor.constraint(equalTo: durationShell.trailingAnchor, constant: -8),
|
|
|
|
|
+ durationField.centerYAnchor.constraint(equalTo: durationShell.centerYAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+ self.durationField = durationField
|
|
|
|
|
+
|
|
|
|
|
+ let startGroup = NSStackView(views: [startLabel, pickerShell])
|
|
|
|
|
+ startGroup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ startGroup.orientation = .vertical
|
|
|
|
|
+ startGroup.alignment = .leading
|
|
|
|
|
+ startGroup.spacing = 6
|
|
|
|
|
+
|
|
|
|
|
+ let durationGroup = NSStackView(views: [durationLabel, durationShell])
|
|
|
|
|
+ durationGroup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ durationGroup.orientation = .vertical
|
|
|
|
|
+ durationGroup.alignment = .leading
|
|
|
|
|
+ durationGroup.spacing = 6
|
|
|
|
|
+
|
|
|
|
|
+ timeRow.addArrangedSubview(startGroup)
|
|
|
|
|
+ timeRow.addArrangedSubview(durationGroup)
|
|
|
|
|
+ startGroup.widthAnchor.constraint(equalTo: durationGroup.widthAnchor).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ let notesLabel = NSTextField(labelWithString: "Notes")
|
|
|
|
|
+ notesLabel.font = typography.fieldLabel
|
|
|
|
|
+ notesLabel.textColor = palette.textSecondary
|
|
|
|
|
+
|
|
|
|
|
+ let notesScroll = NSScrollView()
|
|
|
|
|
+ notesScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ notesScroll.drawsBackground = true
|
|
|
|
|
+ notesScroll.backgroundColor = palette.inputBackground
|
|
|
|
|
+ notesScroll.hasVerticalScroller = true
|
|
|
|
|
+ notesScroll.borderType = .noBorder
|
|
|
|
|
+ notesScroll.wantsLayer = true
|
|
|
|
|
+ notesScroll.layer?.cornerRadius = 8
|
|
|
|
|
+ notesScroll.layer?.borderWidth = 1
|
|
|
|
|
+ notesScroll.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ notesScroll.heightAnchor.constraint(equalToConstant: 90).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ let notesView = NSTextView()
|
|
|
|
|
+ notesView.drawsBackground = false
|
|
|
|
|
+ notesView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
|
|
|
|
|
+ notesView.textColor = palette.textPrimary
|
|
|
|
|
+ notesView.insertionPointColor = palette.textPrimary
|
|
|
|
|
+ notesScroll.documentView = notesView
|
|
|
|
|
+ self.notesView = notesView
|
|
|
|
|
+
|
|
|
|
|
+ let error = NSTextField(labelWithString: "")
|
|
|
|
|
+ error.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ error.textColor = NSColor.systemRed
|
|
|
|
|
+ error.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
|
|
|
|
|
+ error.isHidden = true
|
|
|
|
|
+ self.errorLabel = error
|
|
|
|
|
+
|
|
|
|
|
+ let actions = NSStackView()
|
|
|
|
|
+ actions.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ actions.orientation = .horizontal
|
|
|
|
|
+ actions.alignment = .centerY
|
|
|
|
|
+ actions.spacing = 10
|
|
|
|
|
+
|
|
|
|
|
+ let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPressed(_:)))
|
|
|
|
|
+ cancel.bezelStyle = .rounded
|
|
|
|
|
+
|
|
|
|
|
+ let save = NSButton(title: "Save", target: self, action: #selector(savePressed(_:)))
|
|
|
|
|
+ save.bezelStyle = .rounded
|
|
|
|
|
+ save.keyEquivalent = "\r"
|
|
|
|
|
+
|
|
|
|
|
+ let actionsSpacer = NSView()
|
|
|
|
|
+ actionsSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ actionsSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ actions.addArrangedSubview(cancel)
|
|
|
|
|
+ actions.addArrangedSubview(actionsSpacer)
|
|
|
|
|
+ actions.addArrangedSubview(save)
|
|
|
|
|
+
|
|
|
|
|
+ stack.addArrangedSubview(header)
|
|
|
|
|
+ stack.addArrangedSubview(titleLabel)
|
|
|
|
|
+ stack.addArrangedSubview(titleShell)
|
|
|
|
|
+ stack.addArrangedSubview(timeRow)
|
|
|
|
|
+ stack.addArrangedSubview(notesLabel)
|
|
|
|
|
+ stack.addArrangedSubview(notesScroll)
|
|
|
|
|
+ stack.addArrangedSubview(error)
|
|
|
|
|
+ stack.addArrangedSubview(actions)
|
|
|
|
|
+
|
|
|
|
|
+ root.addSubview(stack)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ root.widthAnchor.constraint(equalToConstant: 360),
|
|
|
|
|
+ stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
|
|
|
|
|
+ stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
|
|
|
|
|
+ stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
|
|
|
|
|
+ stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12),
|
|
|
|
|
+ actions.widthAnchor.constraint(equalTo: stack.widthAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ view = root
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func cancelPressed(_ sender: NSButton) {
|
|
|
|
|
+ onCancel()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func savePressed(_ sender: NSButton) {
|
|
|
|
|
+ setError(nil)
|
|
|
|
|
+
|
|
|
|
|
+ let title = (titleField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if title.isEmpty {
|
|
|
|
|
+ setError("Please enter a title.")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let durationText = (durationField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ let durationMinutes = Int(durationText) ?? 0
|
|
|
|
|
+ if durationMinutes <= 0 {
|
|
|
|
|
+ setError("Duration must be a positive number of minutes.")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let time = timePicker?.dateValue ?? Date()
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ let dateParts = calendar.dateComponents([.year, .month, .day], from: selectedDate)
|
|
|
|
|
+ let timeParts = calendar.dateComponents([.hour, .minute], from: time)
|
|
|
|
|
+ var merged = DateComponents()
|
|
|
|
|
+ merged.year = dateParts.year
|
|
|
|
|
+ merged.month = dateParts.month
|
|
|
|
|
+ merged.day = dateParts.day
|
|
|
|
|
+ merged.hour = timeParts.hour
|
|
|
|
|
+ merged.minute = timeParts.minute
|
|
|
|
|
+ guard let start = calendar.date(from: merged) else {
|
|
|
|
|
+ setError("Invalid start time.")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60))
|
|
|
|
|
+ let notes = notesView?.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ let cleanedNotes = (notes?.isEmpty == false) ? notes : nil
|
|
|
|
|
+
|
|
|
|
|
+ onSave(Draft(title: title, notes: cleanedNotes, startDate: start, endDate: end))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func setError(_ message: String?) {
|
|
|
|
|
+ guard let errorLabel else { return }
|
|
|
|
|
+ errorLabel.stringValue = message ?? ""
|
|
|
|
|
+ errorLabel.isHidden = message == nil
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// MARK: - Schedule actions (OAuth entry)
|
|
// MARK: - Schedule actions (OAuth entry)
|
|
|
|
|
|
|
|
private extension ViewController {
|
|
private extension ViewController {
|
|
@@ -4684,6 +5503,11 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
scheduleCachedMeetings = []
|
|
scheduleCachedMeetings = []
|
|
|
applySchedulePageFiltersAndRender()
|
|
applySchedulePageFiltersAndRender()
|
|
|
|
|
+ if calendarPageGridStack != nil {
|
|
|
|
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
|
|
+ renderCalendarMonthGrid()
|
|
|
|
|
+ renderCalendarSelectedDay()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -4702,6 +5526,11 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
scheduleCachedMeetings = meetings
|
|
scheduleCachedMeetings = meetings
|
|
|
applySchedulePageFiltersAndRender()
|
|
applySchedulePageFiltersAndRender()
|
|
|
|
|
+ if calendarPageGridStack != nil {
|
|
|
|
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
|
|
+ renderCalendarMonthGrid()
|
|
|
|
|
+ renderCalendarSelectedDay()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
@@ -4716,6 +5545,11 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
scheduleCachedMeetings = []
|
|
scheduleCachedMeetings = []
|
|
|
applySchedulePageFiltersAndRender()
|
|
applySchedulePageFiltersAndRender()
|
|
|
|
|
+ if calendarPageGridStack != nil {
|
|
|
|
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
|
|
+ renderCalendarMonthGrid()
|
|
|
|
|
+ renderCalendarSelectedDay()
|
|
|
|
|
+ }
|
|
|
showSimpleError("Couldn’t load schedule.", error: error)
|
|
showSimpleError("Couldn’t load schedule.", error: error)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|