|
|
@@ -353,6 +353,16 @@ final class ViewController: NSViewController {
|
|
353
|
353
|
private weak var schedulePageCardsStack: NSStackView?
|
|
354
|
354
|
private weak var schedulePageCardsScrollView: NSScrollView?
|
|
355
|
355
|
|
|
|
356
|
+ // MARK: - Calendar page (custom month UI)
|
|
|
357
|
+ private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
|
|
|
358
|
+ private var calendarPageSelectedDate: Date = Calendar.current.startOfDay(for: Date())
|
|
|
359
|
+ private weak var calendarPageMonthLabel: NSTextField?
|
|
|
360
|
+ private weak var calendarPageGridStack: NSStackView?
|
|
|
361
|
+ private var calendarPageGridHeightConstraint: NSLayoutConstraint?
|
|
|
362
|
+ private weak var calendarPageDaySummaryLabel: NSTextField?
|
|
|
363
|
+ private var calendarPageActionPopover: NSPopover?
|
|
|
364
|
+ private var calendarPageCreatePopover: NSPopover?
|
|
|
365
|
+
|
|
356
|
366
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
357
|
367
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
358
|
368
|
|
|
|
@@ -1356,7 +1366,7 @@ private extension ViewController {
|
|
1356
|
1366
|
case .photo:
|
|
1357
|
1367
|
built = makeSchedulePageContent()
|
|
1358
|
1368
|
case .video:
|
|
1359
|
|
- built = makePlaceholderPage(title: "Calendar", subtitle: "View meetings by date and track your plan.")
|
|
|
1369
|
+ built = makeCalendarPageContent()
|
|
1360
|
1370
|
case .settings:
|
|
1361
|
1371
|
built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
|
|
1362
|
1372
|
}
|
|
|
@@ -1821,6 +1831,155 @@ private extension ViewController {
|
|
1821
|
1831
|
return panel
|
|
1822
|
1832
|
}
|
|
1823
|
1833
|
|
|
|
1834
|
+ func makeCalendarPageContent() -> NSView {
|
|
|
1835
|
+ let panel = NSView()
|
|
|
1836
|
+ panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1837
|
+ panel.userInterfaceLayoutDirection = .leftToRight
|
|
|
1838
|
+
|
|
|
1839
|
+ let contentStack = NSStackView()
|
|
|
1840
|
+ contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1841
|
+ contentStack.userInterfaceLayoutDirection = .leftToRight
|
|
|
1842
|
+ contentStack.orientation = .vertical
|
|
|
1843
|
+ contentStack.spacing = 14
|
|
|
1844
|
+ contentStack.alignment = .width
|
|
|
1845
|
+ contentStack.distribution = .fill
|
|
|
1846
|
+
|
|
|
1847
|
+ let titleRow = NSStackView()
|
|
|
1848
|
+ titleRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1849
|
+ titleRow.userInterfaceLayoutDirection = .leftToRight
|
|
|
1850
|
+ titleRow.orientation = .horizontal
|
|
|
1851
|
+ titleRow.alignment = .centerY
|
|
|
1852
|
+ titleRow.distribution = .fill
|
|
|
1853
|
+ titleRow.spacing = 12
|
|
|
1854
|
+
|
|
|
1855
|
+ let titleLabel = textLabel("Calendar", font: typography.pageTitle, color: palette.textPrimary)
|
|
|
1856
|
+ titleLabel.alignment = .left
|
|
|
1857
|
+ titleLabel.maximumNumberOfLines = 1
|
|
|
1858
|
+ titleLabel.lineBreakMode = .byTruncatingTail
|
|
|
1859
|
+ titleLabel.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
1860
|
+ titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
1861
|
+
|
|
|
1862
|
+ let spacer = NSView()
|
|
|
1863
|
+ spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1864
|
+ spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
1865
|
+ spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
1866
|
+
|
|
|
1867
|
+ let prevButton = makeCalendarHeaderPillButton(title: "‹", action: #selector(calendarPrevMonthPressed(_:)))
|
|
|
1868
|
+ prevButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
1869
|
+ let nextButton = makeCalendarHeaderPillButton(title: "›", action: #selector(calendarNextMonthPressed(_:)))
|
|
|
1870
|
+ nextButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
1871
|
+
|
|
|
1872
|
+ let monthLabel = textLabel("", font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: palette.textSecondary)
|
|
|
1873
|
+ monthLabel.alignment = .right
|
|
|
1874
|
+ monthLabel.maximumNumberOfLines = 1
|
|
|
1875
|
+ monthLabel.lineBreakMode = .byTruncatingTail
|
|
|
1876
|
+ monthLabel.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
1877
|
+ monthLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
1878
|
+ calendarPageMonthLabel = monthLabel
|
|
|
1879
|
+
|
|
|
1880
|
+ let refreshButton = HoverButton(title: "", target: self, action: #selector(calendarRefreshPressed(_:)))
|
|
|
1881
|
+ refreshButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1882
|
+ refreshButton.isBordered = false
|
|
|
1883
|
+ refreshButton.bezelStyle = .regularSquare
|
|
|
1884
|
+ refreshButton.wantsLayer = true
|
|
|
1885
|
+ refreshButton.layer?.cornerRadius = 15
|
|
|
1886
|
+ refreshButton.layer?.masksToBounds = true
|
|
|
1887
|
+ refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
1888
|
+ refreshButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
1889
|
+ refreshButton.layer?.borderWidth = 1
|
|
|
1890
|
+ refreshButton.setButtonType(.momentaryChange)
|
|
|
1891
|
+ refreshButton.contentTintColor = palette.textSecondary
|
|
|
1892
|
+ refreshButton.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Sync calendar")
|
|
|
1893
|
+ refreshButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
|
|
1894
|
+ refreshButton.imagePosition = .imageOnly
|
|
|
1895
|
+ refreshButton.imageScaling = .scaleProportionallyDown
|
|
|
1896
|
+ refreshButton.focusRingType = .none
|
|
|
1897
|
+ refreshButton.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
|
|
1898
|
+ refreshButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
|
|
|
1899
|
+
|
|
|
1900
|
+ titleRow.addArrangedSubview(titleLabel)
|
|
|
1901
|
+ titleRow.addArrangedSubview(spacer)
|
|
|
1902
|
+ titleRow.addArrangedSubview(prevButton)
|
|
|
1903
|
+ titleRow.addArrangedSubview(nextButton)
|
|
|
1904
|
+ titleRow.addArrangedSubview(monthLabel)
|
|
|
1905
|
+ titleRow.addArrangedSubview(refreshButton)
|
|
|
1906
|
+
|
|
|
1907
|
+ let weekdayRow = NSStackView()
|
|
|
1908
|
+ weekdayRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1909
|
+ weekdayRow.userInterfaceLayoutDirection = .leftToRight
|
|
|
1910
|
+ weekdayRow.orientation = .horizontal
|
|
|
1911
|
+ weekdayRow.alignment = .centerY
|
|
|
1912
|
+ weekdayRow.distribution = .fillEqually
|
|
|
1913
|
+ weekdayRow.spacing = 10
|
|
|
1914
|
+
|
|
|
1915
|
+ for symbol in calendarWeekdaySymbolsStartingAtFirstWeekday() {
|
|
|
1916
|
+ let label = textLabel(symbol, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textMuted)
|
|
|
1917
|
+ label.alignment = .center
|
|
|
1918
|
+ label.maximumNumberOfLines = 1
|
|
|
1919
|
+ label.lineBreakMode = .byClipping
|
|
|
1920
|
+ weekdayRow.addArrangedSubview(label)
|
|
|
1921
|
+ }
|
|
|
1922
|
+
|
|
|
1923
|
+ let gridStack = NSStackView()
|
|
|
1924
|
+ gridStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1925
|
+ gridStack.userInterfaceLayoutDirection = .leftToRight
|
|
|
1926
|
+ gridStack.orientation = .vertical
|
|
|
1927
|
+ gridStack.alignment = .width
|
|
|
1928
|
+ gridStack.distribution = .fillEqually
|
|
|
1929
|
+ gridStack.spacing = 10
|
|
|
1930
|
+ calendarPageGridStack = gridStack
|
|
|
1931
|
+
|
|
|
1932
|
+ let gridCard = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
|
|
|
1933
|
+ gridCard.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1934
|
+ styleSurface(gridCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
1935
|
+ let gridHeightConstraint = gridCard.heightAnchor.constraint(equalToConstant: 320)
|
|
|
1936
|
+ gridHeightConstraint.isActive = true
|
|
|
1937
|
+ calendarPageGridHeightConstraint = gridHeightConstraint
|
|
|
1938
|
+ gridCard.addSubview(gridStack)
|
|
|
1939
|
+ NSLayoutConstraint.activate([
|
|
|
1940
|
+ gridStack.leadingAnchor.constraint(equalTo: gridCard.leadingAnchor, constant: 12),
|
|
|
1941
|
+ gridStack.trailingAnchor.constraint(equalTo: gridCard.trailingAnchor, constant: -12),
|
|
|
1942
|
+ gridStack.topAnchor.constraint(equalTo: gridCard.topAnchor, constant: 12),
|
|
|
1943
|
+ gridStack.bottomAnchor.constraint(equalTo: gridCard.bottomAnchor, constant: -12)
|
|
|
1944
|
+ ])
|
|
|
1945
|
+
|
|
|
1946
|
+ let daySummary = textLabel("", font: typography.dateHeading, color: palette.textSecondary)
|
|
|
1947
|
+ daySummary.alignment = .left
|
|
|
1948
|
+ daySummary.maximumNumberOfLines = 2
|
|
|
1949
|
+ daySummary.lineBreakMode = .byWordWrapping
|
|
|
1950
|
+ calendarPageDaySummaryLabel = daySummary
|
|
|
1951
|
+
|
|
|
1952
|
+ contentStack.addArrangedSubview(titleRow)
|
|
|
1953
|
+ contentStack.addArrangedSubview(weekdayRow)
|
|
|
1954
|
+ contentStack.addArrangedSubview(gridCard)
|
|
|
1955
|
+ contentStack.addArrangedSubview(daySummary)
|
|
|
1956
|
+
|
|
|
1957
|
+ panel.addSubview(contentStack)
|
|
|
1958
|
+ NSLayoutConstraint.activate([
|
|
|
1959
|
+ contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28),
|
|
|
1960
|
+ contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28),
|
|
|
1961
|
+ contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
|
|
|
1962
|
+ contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -16),
|
|
|
1963
|
+ titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
|
|
|
1964
|
+ weekdayRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
|
|
|
1965
|
+ gridCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
|
|
|
1966
|
+ daySummary.widthAnchor.constraint(equalTo: contentStack.widthAnchor)
|
|
|
1967
|
+ ])
|
|
|
1968
|
+
|
|
|
1969
|
+ let calendar = Calendar.current
|
|
|
1970
|
+ calendarPageMonthAnchor = calendarStartOfMonth(for: Date())
|
|
|
1971
|
+ calendarPageSelectedDate = calendar.startOfDay(for: Date())
|
|
|
1972
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
1973
|
+ renderCalendarMonthGrid()
|
|
|
1974
|
+ renderCalendarSelectedDay()
|
|
|
1975
|
+
|
|
|
1976
|
+ Task { [weak self] in
|
|
|
1977
|
+ await self?.loadSchedule()
|
|
|
1978
|
+ }
|
|
|
1979
|
+
|
|
|
1980
|
+ return panel
|
|
|
1981
|
+ }
|
|
|
1982
|
+
|
|
1824
|
1983
|
func meetJoinSectionRow() -> NSView {
|
|
1825
|
1984
|
let row = NSStackView()
|
|
1826
|
1985
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -4312,6 +4471,666 @@ private extension ViewController {
|
|
4312
|
4471
|
}
|
|
4313
|
4472
|
}
|
|
4314
|
4473
|
|
|
|
4474
|
+private let calendarDayKeyFormatter: DateFormatter = {
|
|
|
4475
|
+ let f = DateFormatter()
|
|
|
4476
|
+ f.calendar = Calendar(identifier: .gregorian)
|
|
|
4477
|
+ f.locale = Locale(identifier: "en_US_POSIX")
|
|
|
4478
|
+ f.timeZone = TimeZone.current
|
|
|
4479
|
+ f.dateFormat = "yyyy-MM-dd"
|
|
|
4480
|
+ return f
|
|
|
4481
|
+}()
|
|
|
4482
|
+
|
|
|
4483
|
+// MARK: - Calendar page actions + rendering
|
|
|
4484
|
+
|
|
|
4485
|
+private extension ViewController {
|
|
|
4486
|
+ private func makeCalendarHeaderPillButton(title: String, action: Selector) -> NSButton {
|
|
|
4487
|
+ let button = makeSchedulePillButton(title: title)
|
|
|
4488
|
+ button.target = self
|
|
|
4489
|
+ button.action = action
|
|
|
4490
|
+ button.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
|
|
4491
|
+ return button
|
|
|
4492
|
+ }
|
|
|
4493
|
+
|
|
|
4494
|
+ private func calendarStartOfMonth(for date: Date) -> Date {
|
|
|
4495
|
+ let calendar = Calendar.current
|
|
|
4496
|
+ let comps = calendar.dateComponents([.year, .month], from: date)
|
|
|
4497
|
+ return calendar.date(from: comps) ?? calendar.startOfDay(for: date)
|
|
|
4498
|
+ }
|
|
|
4499
|
+
|
|
|
4500
|
+ private func calendarMonthTitleText(for monthAnchor: Date) -> String {
|
|
|
4501
|
+ let f = DateFormatter()
|
|
|
4502
|
+ f.locale = Locale.current
|
|
|
4503
|
+ f.timeZone = TimeZone.current
|
|
|
4504
|
+ f.dateFormat = "MMMM yyyy"
|
|
|
4505
|
+ return f.string(from: monthAnchor)
|
|
|
4506
|
+ }
|
|
|
4507
|
+
|
|
|
4508
|
+ private func calendarWeekdaySymbolsStartingAtFirstWeekday() -> [String] {
|
|
|
4509
|
+ // Align weekday header to Calendar.current.firstWeekday
|
|
|
4510
|
+ let calendar = Calendar.current
|
|
|
4511
|
+ var symbols = DateFormatter().veryShortWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"]
|
|
|
4512
|
+ // veryShortWeekdaySymbols starts with Sunday in most locales; rotate to firstWeekday.
|
|
|
4513
|
+ let first = max(1, min(7, calendar.firstWeekday)) // 1..7
|
|
|
4514
|
+ let shift = (first - 1) % 7
|
|
|
4515
|
+ if shift == 0 { return symbols }
|
|
|
4516
|
+ let head = Array(symbols[shift...])
|
|
|
4517
|
+ let tail = Array(symbols[..<shift])
|
|
|
4518
|
+ symbols = head + tail
|
|
|
4519
|
+ return symbols
|
|
|
4520
|
+ }
|
|
|
4521
|
+
|
|
|
4522
|
+ @objc func calendarPrevMonthPressed(_ sender: NSButton) {
|
|
|
4523
|
+ let calendar = Calendar.current
|
|
|
4524
|
+ calendarPageMonthAnchor = calendar.date(byAdding: .month, value: -1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
|
|
|
4525
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
4526
|
+ renderCalendarMonthGrid()
|
|
|
4527
|
+ }
|
|
|
4528
|
+
|
|
|
4529
|
+ @objc func calendarNextMonthPressed(_ sender: NSButton) {
|
|
|
4530
|
+ let calendar = Calendar.current
|
|
|
4531
|
+ calendarPageMonthAnchor = calendar.date(byAdding: .month, value: 1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
|
|
|
4532
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
4533
|
+ renderCalendarMonthGrid()
|
|
|
4534
|
+ }
|
|
|
4535
|
+
|
|
|
4536
|
+ @objc func calendarRefreshPressed(_ sender: NSButton) {
|
|
|
4537
|
+ Task { [weak self] in
|
|
|
4538
|
+ await self?.loadSchedule()
|
|
|
4539
|
+ }
|
|
|
4540
|
+ }
|
|
|
4541
|
+
|
|
|
4542
|
+ @objc func calendarDayCellPressed(_ sender: NSButton) {
|
|
|
4543
|
+ guard let raw = sender.identifier?.rawValue,
|
|
|
4544
|
+ let date = calendarDayKeyFormatter.date(from: raw) else { return }
|
|
|
4545
|
+ calendarPageSelectedDate = Calendar.current.startOfDay(for: date)
|
|
|
4546
|
+ renderCalendarMonthGrid()
|
|
|
4547
|
+ renderCalendarSelectedDay()
|
|
|
4548
|
+ if let refreshedButton = calendarButton(forDateKey: raw) {
|
|
|
4549
|
+ showCalendarDayActionPopover(relativeTo: refreshedButton)
|
|
|
4550
|
+ }
|
|
|
4551
|
+ }
|
|
|
4552
|
+
|
|
|
4553
|
+ private func showCalendarDayActionPopover(relativeTo anchor: NSView) {
|
|
|
4554
|
+ guard anchor.window != nil else { return }
|
|
|
4555
|
+ calendarPageActionPopover?.performClose(nil)
|
|
|
4556
|
+ let popover = NSPopover()
|
|
|
4557
|
+ popover.behavior = .transient
|
|
|
4558
|
+ popover.animates = true
|
|
|
4559
|
+ popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
|
4560
|
+ popover.contentViewController = CalendarDayActionMenuViewController(
|
|
|
4561
|
+ palette: palette,
|
|
|
4562
|
+ onSchedule: { [weak self] in
|
|
|
4563
|
+ self?.calendarPageActionPopover?.performClose(nil)
|
|
|
4564
|
+ self?.calendarPageActionPopover = nil
|
|
|
4565
|
+ self?.presentCreateMeetingPopover(relativeTo: anchor)
|
|
|
4566
|
+ }
|
|
|
4567
|
+ )
|
|
|
4568
|
+ calendarPageActionPopover = popover
|
|
|
4569
|
+ popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
|
|
|
4570
|
+ }
|
|
|
4571
|
+
|
|
|
4572
|
+ private func presentCreateMeetingPopover(relativeTo anchor: NSView) {
|
|
|
4573
|
+ guard anchor.window != nil else { return }
|
|
|
4574
|
+ calendarPageCreatePopover?.performClose(nil)
|
|
|
4575
|
+ let popover = NSPopover()
|
|
|
4576
|
+ popover.behavior = .transient
|
|
|
4577
|
+ popover.animates = true
|
|
|
4578
|
+ popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
|
4579
|
+ let selectedDate = Calendar.current.startOfDay(for: calendarPageSelectedDate)
|
|
|
4580
|
+ let vc = CreateMeetingPopoverViewController(
|
|
|
4581
|
+ palette: palette,
|
|
|
4582
|
+ typography: typography,
|
|
|
4583
|
+ selectedDate: selectedDate,
|
|
|
4584
|
+ onCancel: { [weak self] in
|
|
|
4585
|
+ self?.calendarPageCreatePopover?.performClose(nil)
|
|
|
4586
|
+ self?.calendarPageCreatePopover = nil
|
|
|
4587
|
+ },
|
|
|
4588
|
+ onSave: { [weak self] draft in
|
|
|
4589
|
+ guard let self else { return }
|
|
|
4590
|
+ self.calendarPageCreatePopover?.performClose(nil)
|
|
|
4591
|
+ self.calendarPageCreatePopover = nil
|
|
|
4592
|
+ self.calendarCreateMeeting(title: draft.title, notes: draft.notes, start: draft.startDate, end: draft.endDate)
|
|
|
4593
|
+ }
|
|
|
4594
|
+ )
|
|
|
4595
|
+ popover.contentViewController = vc
|
|
|
4596
|
+ calendarPageCreatePopover = popover
|
|
|
4597
|
+ popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
|
|
|
4598
|
+ }
|
|
|
4599
|
+
|
|
|
4600
|
+ private func calendarCreateMeeting(title: String, notes: String?, start: Date, end: Date) {
|
|
|
4601
|
+ Task { [weak self] in
|
|
|
4602
|
+ guard let self else { return }
|
|
|
4603
|
+ do {
|
|
|
4604
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
4605
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
4606
|
+ _ = try await self.calendarClient.createEvent(
|
|
|
4607
|
+ accessToken: token,
|
|
|
4608
|
+ title: title,
|
|
|
4609
|
+ description: notes,
|
|
|
4610
|
+ start: start,
|
|
|
4611
|
+ end: end,
|
|
|
4612
|
+ timeZone: .current
|
|
|
4613
|
+ )
|
|
|
4614
|
+ await self.loadSchedule()
|
|
|
4615
|
+ await MainActor.run {
|
|
|
4616
|
+ let calendar = Calendar.current
|
|
|
4617
|
+ self.calendarPageSelectedDate = calendar.startOfDay(for: start)
|
|
|
4618
|
+ self.calendarPageMonthAnchor = self.calendarStartOfMonth(for: start)
|
|
|
4619
|
+ self.calendarPageMonthLabel?.stringValue = self.calendarMonthTitleText(for: self.calendarPageMonthAnchor)
|
|
|
4620
|
+ self.renderCalendarMonthGrid()
|
|
|
4621
|
+ self.renderCalendarSelectedDay()
|
|
|
4622
|
+ }
|
|
|
4623
|
+ } catch {
|
|
|
4624
|
+ self.showSimpleError("Couldn’t create meeting.", error: error)
|
|
|
4625
|
+ }
|
|
|
4626
|
+ }
|
|
|
4627
|
+ }
|
|
|
4628
|
+
|
|
|
4629
|
+ private func renderCalendarMonthGrid() {
|
|
|
4630
|
+ guard let gridStack = calendarPageGridStack else { return }
|
|
|
4631
|
+ gridStack.arrangedSubviews.forEach { v in
|
|
|
4632
|
+ gridStack.removeArrangedSubview(v)
|
|
|
4633
|
+ v.removeFromSuperview()
|
|
|
4634
|
+ }
|
|
|
4635
|
+
|
|
|
4636
|
+ let calendar = Calendar.current
|
|
|
4637
|
+ let monthStart = calendarStartOfMonth(for: calendarPageMonthAnchor)
|
|
|
4638
|
+ guard let dayRange = calendar.range(of: .day, in: .month, for: monthStart),
|
|
|
4639
|
+ let monthEnd = calendar.date(byAdding: DateComponents(month: 1, day: 0), to: monthStart) else { return }
|
|
|
4640
|
+
|
|
|
4641
|
+ let firstWeekday = calendar.component(.weekday, from: monthStart) // 1..7
|
|
|
4642
|
+ let leadingEmpty = (firstWeekday - calendar.firstWeekday + 7) % 7
|
|
|
4643
|
+ let totalDays = dayRange.count
|
|
|
4644
|
+ let totalCells = leadingEmpty + totalDays
|
|
|
4645
|
+ let rowCount = Int(ceil(Double(totalCells) / 7.0))
|
|
|
4646
|
+ let rowHeight: CGFloat = 44
|
|
|
4647
|
+ let rowSpacing: CGFloat = 10
|
|
|
4648
|
+ let verticalPadding: CGFloat = 24
|
|
|
4649
|
+ calendarPageGridHeightConstraint?.constant = verticalPadding + (CGFloat(rowCount) * rowHeight) + (CGFloat(max(0, rowCount - 1)) * rowSpacing)
|
|
|
4650
|
+
|
|
|
4651
|
+ let meetingCounts = calendarMeetingCountsByDay(from: scheduleCachedMeetings, monthStart: monthStart, monthEnd: monthEnd)
|
|
|
4652
|
+
|
|
|
4653
|
+ var day = 1
|
|
|
4654
|
+ for _ in 0..<rowCount {
|
|
|
4655
|
+ let row = NSStackView()
|
|
|
4656
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4657
|
+ row.userInterfaceLayoutDirection = .leftToRight
|
|
|
4658
|
+ row.orientation = .horizontal
|
|
|
4659
|
+ row.alignment = .top
|
|
|
4660
|
+ row.distribution = .fillEqually
|
|
|
4661
|
+ row.spacing = rowSpacing
|
|
|
4662
|
+ row.heightAnchor.constraint(equalToConstant: rowHeight).isActive = true
|
|
|
4663
|
+
|
|
|
4664
|
+ for col in 0..<7 {
|
|
|
4665
|
+ let cellIndex = (gridStack.arrangedSubviews.count * 7) + col
|
|
|
4666
|
+ if cellIndex < leadingEmpty || day > totalDays {
|
|
|
4667
|
+ row.addArrangedSubview(calendarEmptyDayCell())
|
|
|
4668
|
+ continue
|
|
|
4669
|
+ }
|
|
|
4670
|
+ guard let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) else {
|
|
|
4671
|
+ row.addArrangedSubview(calendarEmptyDayCell())
|
|
|
4672
|
+ continue
|
|
|
4673
|
+ }
|
|
|
4674
|
+ let isSelected = calendar.isDate(date, inSameDayAs: calendarPageSelectedDate)
|
|
|
4675
|
+ let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: date))
|
|
|
4676
|
+ let count = meetingCounts[key] ?? 0
|
|
|
4677
|
+ row.addArrangedSubview(calendarDayCell(dayNumber: day, dateKey: key, meetingCount: count, isSelected: isSelected))
|
|
|
4678
|
+ day += 1
|
|
|
4679
|
+ }
|
|
|
4680
|
+ gridStack.addArrangedSubview(row)
|
|
|
4681
|
+ row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
|
|
|
4682
|
+ }
|
|
|
4683
|
+ }
|
|
|
4684
|
+
|
|
|
4685
|
+ private func calendarButton(forDateKey key: String) -> NSButton? {
|
|
|
4686
|
+ guard let gridStack = calendarPageGridStack else { return nil }
|
|
|
4687
|
+ for rowView in gridStack.arrangedSubviews {
|
|
|
4688
|
+ guard let row = rowView as? NSStackView else { continue }
|
|
|
4689
|
+ for cell in row.arrangedSubviews {
|
|
|
4690
|
+ if let button = cell as? NSButton, button.identifier?.rawValue == key {
|
|
|
4691
|
+ return button
|
|
|
4692
|
+ }
|
|
|
4693
|
+ }
|
|
|
4694
|
+ }
|
|
|
4695
|
+ return nil
|
|
|
4696
|
+ }
|
|
|
4697
|
+
|
|
|
4698
|
+ private func renderCalendarSelectedDay() {
|
|
|
4699
|
+ let calendar = Calendar.current
|
|
|
4700
|
+ let selectedDay = calendar.startOfDay(for: calendarPageSelectedDate)
|
|
|
4701
|
+ let nextDay = calendar.date(byAdding: .day, value: 1, to: selectedDay) ?? selectedDay.addingTimeInterval(86400)
|
|
|
4702
|
+
|
|
|
4703
|
+ let meetings = scheduleCachedMeetings
|
|
|
4704
|
+ .filter { $0.startDate >= selectedDay && $0.startDate < nextDay }
|
|
|
4705
|
+ .sorted(by: { $0.startDate < $1.startDate })
|
|
|
4706
|
+
|
|
|
4707
|
+ let f = DateFormatter()
|
|
|
4708
|
+ f.locale = Locale.current
|
|
|
4709
|
+ f.timeZone = TimeZone.current
|
|
|
4710
|
+ f.dateFormat = "EEE, d MMM"
|
|
|
4711
|
+
|
|
|
4712
|
+ if meetings.isEmpty {
|
|
|
4713
|
+ calendarPageDaySummaryLabel?.stringValue = googleOAuth.loadTokens() == nil
|
|
|
4714
|
+ ? "Connect Google to see meetings"
|
|
|
4715
|
+ : "No meetings on \(f.string(from: selectedDay))"
|
|
|
4716
|
+ } else if meetings.count == 1 {
|
|
|
4717
|
+ calendarPageDaySummaryLabel?.stringValue = "1 meeting on \(f.string(from: selectedDay))"
|
|
|
4718
|
+ } else {
|
|
|
4719
|
+ calendarPageDaySummaryLabel?.stringValue = "\(meetings.count) meetings on \(f.string(from: selectedDay))"
|
|
|
4720
|
+ }
|
|
|
4721
|
+ }
|
|
|
4722
|
+
|
|
|
4723
|
+ private func calendarMeetingCountsByDay(from meetings: [ScheduledMeeting], monthStart: Date, monthEnd: Date) -> [String: Int] {
|
|
|
4724
|
+ let calendar = Calendar.current
|
|
|
4725
|
+ var counts: [String: Int] = [:]
|
|
|
4726
|
+ for meeting in meetings {
|
|
|
4727
|
+ guard meeting.startDate >= monthStart && meeting.startDate < monthEnd else { continue }
|
|
|
4728
|
+ let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: meeting.startDate))
|
|
|
4729
|
+ counts[key, default: 0] += 1
|
|
|
4730
|
+ }
|
|
|
4731
|
+ return counts
|
|
|
4732
|
+ }
|
|
|
4733
|
+
|
|
|
4734
|
+ private func calendarEmptyDayCell() -> NSView {
|
|
|
4735
|
+ let v = NSView()
|
|
|
4736
|
+ v.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4737
|
+ v.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
4738
|
+ return v
|
|
|
4739
|
+ }
|
|
|
4740
|
+
|
|
|
4741
|
+ private func calendarDayCell(dayNumber: Int, dateKey: String, meetingCount: Int, isSelected: Bool) -> NSButton {
|
|
|
4742
|
+ let button = HoverButton(title: "", target: self, action: #selector(calendarDayCellPressed(_:)))
|
|
|
4743
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4744
|
+ button.isBordered = false
|
|
|
4745
|
+ button.bezelStyle = .regularSquare
|
|
|
4746
|
+ button.wantsLayer = true
|
|
|
4747
|
+ button.layer?.cornerRadius = 10
|
|
|
4748
|
+ button.layer?.masksToBounds = true
|
|
|
4749
|
+ button.identifier = NSUserInterfaceItemIdentifier(dateKey)
|
|
|
4750
|
+ button.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
4751
|
+ button.setContentHuggingPriority(.required, for: .vertical)
|
|
|
4752
|
+ button.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
4753
|
+ button.alignment = .left
|
|
|
4754
|
+ button.imagePosition = .noImage
|
|
|
4755
|
+
|
|
|
4756
|
+ let base = palette.inputBackground
|
|
|
4757
|
+ let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
|
|
|
4758
|
+ let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
|
|
|
4759
|
+ let selectedBackground = darkModeEnabled
|
|
|
4760
|
+ ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
|
|
|
4761
|
+ : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
|
|
|
4762
|
+ let borderIdle = palette.inputBorder
|
|
|
4763
|
+ let borderSelected = palette.primaryBlueBorder
|
|
|
4764
|
+
|
|
|
4765
|
+ func applyAppearance(hovering: Bool) {
|
|
|
4766
|
+ button.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hover : base)).cgColor
|
|
|
4767
|
+ button.layer?.borderWidth = isSelected ? 1.5 : 1
|
|
|
4768
|
+ button.layer?.borderColor = (isSelected ? borderSelected : borderIdle).cgColor
|
|
|
4769
|
+ }
|
|
|
4770
|
+ applyAppearance(hovering: false)
|
|
|
4771
|
+ button.onHoverChanged = { hovering in
|
|
|
4772
|
+ applyAppearance(hovering: hovering)
|
|
|
4773
|
+ }
|
|
|
4774
|
+
|
|
|
4775
|
+ button.attributedTitle = NSAttributedString(
|
|
|
4776
|
+ string: " \(dayNumber)",
|
|
|
4777
|
+ attributes: [
|
|
|
4778
|
+ .font: NSFont.systemFont(ofSize: 14, weight: .bold),
|
|
|
4779
|
+ .foregroundColor: palette.textPrimary
|
|
|
4780
|
+ ]
|
|
|
4781
|
+ )
|
|
|
4782
|
+
|
|
|
4783
|
+ if meetingCount > 0 {
|
|
|
4784
|
+ let dot = roundedContainer(cornerRadius: 4, color: palette.meetingBadge)
|
|
|
4785
|
+ dot.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4786
|
+ dot.layer?.borderWidth = 0
|
|
|
4787
|
+ button.addSubview(dot)
|
|
|
4788
|
+ NSLayoutConstraint.activate([
|
|
|
4789
|
+ dot.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -10),
|
|
|
4790
|
+ dot.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
|
|
4791
|
+ dot.widthAnchor.constraint(equalToConstant: 8),
|
|
|
4792
|
+ dot.heightAnchor.constraint(equalToConstant: 8)
|
|
|
4793
|
+ ])
|
|
|
4794
|
+ }
|
|
|
4795
|
+
|
|
|
4796
|
+ return button
|
|
|
4797
|
+ }
|
|
|
4798
|
+}
|
|
|
4799
|
+
|
|
|
4800
|
+private final class CalendarDayActionMenuViewController: NSViewController {
|
|
|
4801
|
+ private let palette: Palette
|
|
|
4802
|
+ private let onSchedule: () -> Void
|
|
|
4803
|
+
|
|
|
4804
|
+ init(palette: Palette, onSchedule: @escaping () -> Void) {
|
|
|
4805
|
+ self.palette = palette
|
|
|
4806
|
+ self.onSchedule = onSchedule
|
|
|
4807
|
+ super.init(nibName: nil, bundle: nil)
|
|
|
4808
|
+ }
|
|
|
4809
|
+
|
|
|
4810
|
+ required init?(coder: NSCoder) { nil }
|
|
|
4811
|
+
|
|
|
4812
|
+ override func loadView() {
|
|
|
4813
|
+ let root = NSView()
|
|
|
4814
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4815
|
+
|
|
|
4816
|
+ let stack = NSStackView()
|
|
|
4817
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4818
|
+ stack.orientation = .vertical
|
|
|
4819
|
+ stack.alignment = .leading
|
|
|
4820
|
+ stack.spacing = 10
|
|
|
4821
|
+
|
|
|
4822
|
+ let title = NSTextField(labelWithString: "Actions")
|
|
|
4823
|
+ title.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
|
|
|
4824
|
+ title.textColor = palette.textMuted
|
|
|
4825
|
+
|
|
|
4826
|
+ let schedule = NSButton(title: "Schedule meeting", target: self, action: #selector(schedulePressed(_:)))
|
|
|
4827
|
+ schedule.bezelStyle = .rounded
|
|
|
4828
|
+ schedule.font = NSFont.systemFont(ofSize: 13, weight: .medium)
|
|
|
4829
|
+
|
|
|
4830
|
+ stack.addArrangedSubview(title)
|
|
|
4831
|
+ stack.addArrangedSubview(schedule)
|
|
|
4832
|
+ root.addSubview(stack)
|
|
|
4833
|
+
|
|
|
4834
|
+ NSLayoutConstraint.activate([
|
|
|
4835
|
+ root.widthAnchor.constraint(equalToConstant: 220),
|
|
|
4836
|
+ root.heightAnchor.constraint(greaterThanOrEqualToConstant: 86),
|
|
|
4837
|
+ stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
|
|
|
4838
|
+ stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
|
|
|
4839
|
+ stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
|
|
|
4840
|
+ stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12)
|
|
|
4841
|
+ ])
|
|
|
4842
|
+
|
|
|
4843
|
+ view = root
|
|
|
4844
|
+ }
|
|
|
4845
|
+
|
|
|
4846
|
+ @objc private func schedulePressed(_ sender: NSButton) {
|
|
|
4847
|
+ onSchedule()
|
|
|
4848
|
+ }
|
|
|
4849
|
+}
|
|
|
4850
|
+
|
|
|
4851
|
+private final class CreateMeetingPopoverViewController: NSViewController {
|
|
|
4852
|
+ struct Draft {
|
|
|
4853
|
+ let title: String
|
|
|
4854
|
+ let notes: String?
|
|
|
4855
|
+ let startDate: Date
|
|
|
4856
|
+ let endDate: Date
|
|
|
4857
|
+ }
|
|
|
4858
|
+
|
|
|
4859
|
+ private let palette: Palette
|
|
|
4860
|
+ private let typography: Typography
|
|
|
4861
|
+ private let selectedDate: Date
|
|
|
4862
|
+ private let onCancel: () -> Void
|
|
|
4863
|
+ private let onSave: (Draft) -> Void
|
|
|
4864
|
+
|
|
|
4865
|
+ private var titleField: NSTextField?
|
|
|
4866
|
+ private var timePicker: NSDatePicker?
|
|
|
4867
|
+ private var durationField: NSTextField?
|
|
|
4868
|
+ private var notesView: NSTextView?
|
|
|
4869
|
+ private var errorLabel: NSTextField?
|
|
|
4870
|
+
|
|
|
4871
|
+ init(palette: Palette,
|
|
|
4872
|
+ typography: Typography,
|
|
|
4873
|
+ selectedDate: Date,
|
|
|
4874
|
+ onCancel: @escaping () -> Void,
|
|
|
4875
|
+ onSave: @escaping (Draft) -> Void) {
|
|
|
4876
|
+ self.palette = palette
|
|
|
4877
|
+ self.typography = typography
|
|
|
4878
|
+ self.selectedDate = selectedDate
|
|
|
4879
|
+ self.onCancel = onCancel
|
|
|
4880
|
+ self.onSave = onSave
|
|
|
4881
|
+ super.init(nibName: nil, bundle: nil)
|
|
|
4882
|
+ }
|
|
|
4883
|
+
|
|
|
4884
|
+ required init?(coder: NSCoder) { nil }
|
|
|
4885
|
+
|
|
|
4886
|
+ override func loadView() {
|
|
|
4887
|
+ let root = NSView()
|
|
|
4888
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4889
|
+
|
|
|
4890
|
+ let stack = NSStackView()
|
|
|
4891
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4892
|
+ stack.orientation = .vertical
|
|
|
4893
|
+ stack.alignment = .width
|
|
|
4894
|
+ stack.spacing = 12
|
|
|
4895
|
+
|
|
|
4896
|
+ let header = NSTextField(labelWithString: "Schedule meeting")
|
|
|
4897
|
+ header.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
|
|
|
4898
|
+ header.textColor = palette.textPrimary
|
|
|
4899
|
+
|
|
|
4900
|
+ let titleLabel = NSTextField(labelWithString: "Title")
|
|
|
4901
|
+ titleLabel.font = typography.fieldLabel
|
|
|
4902
|
+ titleLabel.textColor = palette.textSecondary
|
|
|
4903
|
+
|
|
|
4904
|
+ let titleShell = NSView()
|
|
|
4905
|
+ titleShell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4906
|
+ titleShell.wantsLayer = true
|
|
|
4907
|
+ titleShell.layer?.cornerRadius = 8
|
|
|
4908
|
+ titleShell.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
4909
|
+ titleShell.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
4910
|
+ titleShell.layer?.borderWidth = 1
|
|
|
4911
|
+ titleShell.heightAnchor.constraint(equalToConstant: 40).isActive = true
|
|
|
4912
|
+
|
|
|
4913
|
+ let titleField = NSTextField(string: "")
|
|
|
4914
|
+ titleField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4915
|
+ titleField.isBordered = false
|
|
|
4916
|
+ titleField.drawsBackground = false
|
|
|
4917
|
+ titleField.focusRingType = .none
|
|
|
4918
|
+ titleField.font = NSFont.systemFont(ofSize: 14, weight: .regular)
|
|
|
4919
|
+ titleField.textColor = palette.textPrimary
|
|
|
4920
|
+ titleField.placeholderString = "Team sync"
|
|
|
4921
|
+ titleShell.addSubview(titleField)
|
|
|
4922
|
+ NSLayoutConstraint.activate([
|
|
|
4923
|
+ titleField.leadingAnchor.constraint(equalTo: titleShell.leadingAnchor, constant: 10),
|
|
|
4924
|
+ titleField.trailingAnchor.constraint(equalTo: titleShell.trailingAnchor, constant: -10),
|
|
|
4925
|
+ titleField.centerYAnchor.constraint(equalTo: titleShell.centerYAnchor)
|
|
|
4926
|
+ ])
|
|
|
4927
|
+ self.titleField = titleField
|
|
|
4928
|
+
|
|
|
4929
|
+ let timeRow = NSStackView()
|
|
|
4930
|
+ timeRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4931
|
+ timeRow.orientation = .horizontal
|
|
|
4932
|
+ timeRow.alignment = .centerY
|
|
|
4933
|
+ timeRow.spacing = 10
|
|
|
4934
|
+ timeRow.distribution = .fill
|
|
|
4935
|
+
|
|
|
4936
|
+ let startLabel = NSTextField(labelWithString: "Start")
|
|
|
4937
|
+ startLabel.font = typography.fieldLabel
|
|
|
4938
|
+ startLabel.textColor = palette.textSecondary
|
|
|
4939
|
+
|
|
|
4940
|
+ let pickerShell = NSView()
|
|
|
4941
|
+ pickerShell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4942
|
+ pickerShell.wantsLayer = true
|
|
|
4943
|
+ pickerShell.layer?.cornerRadius = 8
|
|
|
4944
|
+ pickerShell.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
4945
|
+ pickerShell.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
4946
|
+ pickerShell.layer?.borderWidth = 1
|
|
|
4947
|
+ pickerShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
4948
|
+
|
|
|
4949
|
+ let timePicker = NSDatePicker()
|
|
|
4950
|
+ timePicker.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4951
|
+ timePicker.isBordered = false
|
|
|
4952
|
+ timePicker.drawsBackground = false
|
|
|
4953
|
+ timePicker.focusRingType = .none
|
|
|
4954
|
+ timePicker.datePickerStyle = .textFieldAndStepper
|
|
|
4955
|
+ timePicker.datePickerElements = [.hourMinute]
|
|
|
4956
|
+ timePicker.font = typography.filterText
|
|
|
4957
|
+ timePicker.textColor = palette.textSecondary
|
|
|
4958
|
+ timePicker.dateValue = Date()
|
|
|
4959
|
+ pickerShell.addSubview(timePicker)
|
|
|
4960
|
+ NSLayoutConstraint.activate([
|
|
|
4961
|
+ timePicker.leadingAnchor.constraint(equalTo: pickerShell.leadingAnchor, constant: 8),
|
|
|
4962
|
+ timePicker.trailingAnchor.constraint(equalTo: pickerShell.trailingAnchor, constant: -8),
|
|
|
4963
|
+ timePicker.centerYAnchor.constraint(equalTo: pickerShell.centerYAnchor)
|
|
|
4964
|
+ ])
|
|
|
4965
|
+ self.timePicker = timePicker
|
|
|
4966
|
+
|
|
|
4967
|
+ let durationLabel = NSTextField(labelWithString: "Duration (min)")
|
|
|
4968
|
+ durationLabel.font = typography.fieldLabel
|
|
|
4969
|
+ durationLabel.textColor = palette.textSecondary
|
|
|
4970
|
+
|
|
|
4971
|
+ let durationShell = NSView()
|
|
|
4972
|
+ durationShell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4973
|
+ durationShell.wantsLayer = true
|
|
|
4974
|
+ durationShell.layer?.cornerRadius = 8
|
|
|
4975
|
+ durationShell.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
4976
|
+ durationShell.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
4977
|
+ durationShell.layer?.borderWidth = 1
|
|
|
4978
|
+ durationShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
4979
|
+
|
|
|
4980
|
+ let durationField = NSTextField(string: "30")
|
|
|
4981
|
+ durationField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4982
|
+ durationField.isBordered = false
|
|
|
4983
|
+ durationField.drawsBackground = false
|
|
|
4984
|
+ durationField.focusRingType = .none
|
|
|
4985
|
+ durationField.font = typography.filterText
|
|
|
4986
|
+ durationField.textColor = palette.textSecondary
|
|
|
4987
|
+ durationField.formatter = NumberFormatter()
|
|
|
4988
|
+ durationShell.addSubview(durationField)
|
|
|
4989
|
+ NSLayoutConstraint.activate([
|
|
|
4990
|
+ durationField.leadingAnchor.constraint(equalTo: durationShell.leadingAnchor, constant: 8),
|
|
|
4991
|
+ durationField.trailingAnchor.constraint(equalTo: durationShell.trailingAnchor, constant: -8),
|
|
|
4992
|
+ durationField.centerYAnchor.constraint(equalTo: durationShell.centerYAnchor)
|
|
|
4993
|
+ ])
|
|
|
4994
|
+ self.durationField = durationField
|
|
|
4995
|
+
|
|
|
4996
|
+ let startGroup = NSStackView(views: [startLabel, pickerShell])
|
|
|
4997
|
+ startGroup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
4998
|
+ startGroup.orientation = .vertical
|
|
|
4999
|
+ startGroup.alignment = .leading
|
|
|
5000
|
+ startGroup.spacing = 6
|
|
|
5001
|
+
|
|
|
5002
|
+ let durationGroup = NSStackView(views: [durationLabel, durationShell])
|
|
|
5003
|
+ durationGroup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
5004
|
+ durationGroup.orientation = .vertical
|
|
|
5005
|
+ durationGroup.alignment = .leading
|
|
|
5006
|
+ durationGroup.spacing = 6
|
|
|
5007
|
+
|
|
|
5008
|
+ timeRow.addArrangedSubview(startGroup)
|
|
|
5009
|
+ timeRow.addArrangedSubview(durationGroup)
|
|
|
5010
|
+ startGroup.widthAnchor.constraint(equalTo: durationGroup.widthAnchor).isActive = true
|
|
|
5011
|
+
|
|
|
5012
|
+ let notesLabel = NSTextField(labelWithString: "Notes")
|
|
|
5013
|
+ notesLabel.font = typography.fieldLabel
|
|
|
5014
|
+ notesLabel.textColor = palette.textSecondary
|
|
|
5015
|
+
|
|
|
5016
|
+ let notesScroll = NSScrollView()
|
|
|
5017
|
+ notesScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
5018
|
+ notesScroll.drawsBackground = true
|
|
|
5019
|
+ notesScroll.backgroundColor = palette.inputBackground
|
|
|
5020
|
+ notesScroll.hasVerticalScroller = true
|
|
|
5021
|
+ notesScroll.borderType = .noBorder
|
|
|
5022
|
+ notesScroll.wantsLayer = true
|
|
|
5023
|
+ notesScroll.layer?.cornerRadius = 8
|
|
|
5024
|
+ notesScroll.layer?.borderWidth = 1
|
|
|
5025
|
+ notesScroll.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
5026
|
+ notesScroll.heightAnchor.constraint(equalToConstant: 90).isActive = true
|
|
|
5027
|
+
|
|
|
5028
|
+ let notesView = NSTextView()
|
|
|
5029
|
+ notesView.drawsBackground = false
|
|
|
5030
|
+ notesView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
|
|
|
5031
|
+ notesView.textColor = palette.textPrimary
|
|
|
5032
|
+ notesView.insertionPointColor = palette.textPrimary
|
|
|
5033
|
+ notesScroll.documentView = notesView
|
|
|
5034
|
+ self.notesView = notesView
|
|
|
5035
|
+
|
|
|
5036
|
+ let error = NSTextField(labelWithString: "")
|
|
|
5037
|
+ error.translatesAutoresizingMaskIntoConstraints = false
|
|
|
5038
|
+ error.textColor = NSColor.systemRed
|
|
|
5039
|
+ error.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
|
|
|
5040
|
+ error.isHidden = true
|
|
|
5041
|
+ self.errorLabel = error
|
|
|
5042
|
+
|
|
|
5043
|
+ let actions = NSStackView()
|
|
|
5044
|
+ actions.translatesAutoresizingMaskIntoConstraints = false
|
|
|
5045
|
+ actions.orientation = .horizontal
|
|
|
5046
|
+ actions.alignment = .centerY
|
|
|
5047
|
+ actions.spacing = 10
|
|
|
5048
|
+
|
|
|
5049
|
+ let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPressed(_:)))
|
|
|
5050
|
+ cancel.bezelStyle = .rounded
|
|
|
5051
|
+
|
|
|
5052
|
+ let save = NSButton(title: "Save", target: self, action: #selector(savePressed(_:)))
|
|
|
5053
|
+ save.bezelStyle = .rounded
|
|
|
5054
|
+ save.keyEquivalent = "\r"
|
|
|
5055
|
+
|
|
|
5056
|
+ let actionsSpacer = NSView()
|
|
|
5057
|
+ actionsSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
5058
|
+ actionsSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
5059
|
+
|
|
|
5060
|
+ actions.addArrangedSubview(cancel)
|
|
|
5061
|
+ actions.addArrangedSubview(actionsSpacer)
|
|
|
5062
|
+ actions.addArrangedSubview(save)
|
|
|
5063
|
+
|
|
|
5064
|
+ stack.addArrangedSubview(header)
|
|
|
5065
|
+ stack.addArrangedSubview(titleLabel)
|
|
|
5066
|
+ stack.addArrangedSubview(titleShell)
|
|
|
5067
|
+ stack.addArrangedSubview(timeRow)
|
|
|
5068
|
+ stack.addArrangedSubview(notesLabel)
|
|
|
5069
|
+ stack.addArrangedSubview(notesScroll)
|
|
|
5070
|
+ stack.addArrangedSubview(error)
|
|
|
5071
|
+ stack.addArrangedSubview(actions)
|
|
|
5072
|
+
|
|
|
5073
|
+ root.addSubview(stack)
|
|
|
5074
|
+ NSLayoutConstraint.activate([
|
|
|
5075
|
+ root.widthAnchor.constraint(equalToConstant: 360),
|
|
|
5076
|
+ stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
|
|
|
5077
|
+ stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
|
|
|
5078
|
+ stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
|
|
|
5079
|
+ stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12),
|
|
|
5080
|
+ actions.widthAnchor.constraint(equalTo: stack.widthAnchor)
|
|
|
5081
|
+ ])
|
|
|
5082
|
+
|
|
|
5083
|
+ view = root
|
|
|
5084
|
+ }
|
|
|
5085
|
+
|
|
|
5086
|
+ @objc private func cancelPressed(_ sender: NSButton) {
|
|
|
5087
|
+ onCancel()
|
|
|
5088
|
+ }
|
|
|
5089
|
+
|
|
|
5090
|
+ @objc private func savePressed(_ sender: NSButton) {
|
|
|
5091
|
+ setError(nil)
|
|
|
5092
|
+
|
|
|
5093
|
+ let title = (titleField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
5094
|
+ if title.isEmpty {
|
|
|
5095
|
+ setError("Please enter a title.")
|
|
|
5096
|
+ return
|
|
|
5097
|
+ }
|
|
|
5098
|
+
|
|
|
5099
|
+ let durationText = (durationField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
5100
|
+ let durationMinutes = Int(durationText) ?? 0
|
|
|
5101
|
+ if durationMinutes <= 0 {
|
|
|
5102
|
+ setError("Duration must be a positive number of minutes.")
|
|
|
5103
|
+ return
|
|
|
5104
|
+ }
|
|
|
5105
|
+
|
|
|
5106
|
+ let time = timePicker?.dateValue ?? Date()
|
|
|
5107
|
+ let calendar = Calendar.current
|
|
|
5108
|
+ let dateParts = calendar.dateComponents([.year, .month, .day], from: selectedDate)
|
|
|
5109
|
+ let timeParts = calendar.dateComponents([.hour, .minute], from: time)
|
|
|
5110
|
+ var merged = DateComponents()
|
|
|
5111
|
+ merged.year = dateParts.year
|
|
|
5112
|
+ merged.month = dateParts.month
|
|
|
5113
|
+ merged.day = dateParts.day
|
|
|
5114
|
+ merged.hour = timeParts.hour
|
|
|
5115
|
+ merged.minute = timeParts.minute
|
|
|
5116
|
+ guard let start = calendar.date(from: merged) else {
|
|
|
5117
|
+ setError("Invalid start time.")
|
|
|
5118
|
+ return
|
|
|
5119
|
+ }
|
|
|
5120
|
+ let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60))
|
|
|
5121
|
+ let notes = notesView?.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
5122
|
+ let cleanedNotes = (notes?.isEmpty == false) ? notes : nil
|
|
|
5123
|
+
|
|
|
5124
|
+ onSave(Draft(title: title, notes: cleanedNotes, startDate: start, endDate: end))
|
|
|
5125
|
+ }
|
|
|
5126
|
+
|
|
|
5127
|
+ private func setError(_ message: String?) {
|
|
|
5128
|
+ guard let errorLabel else { return }
|
|
|
5129
|
+ errorLabel.stringValue = message ?? ""
|
|
|
5130
|
+ errorLabel.isHidden = message == nil
|
|
|
5131
|
+ }
|
|
|
5132
|
+}
|
|
|
5133
|
+
|
|
4315
|
5134
|
// MARK: - Schedule actions (OAuth entry)
|
|
4316
|
5135
|
|
|
4317
|
5136
|
private extension ViewController {
|
|
|
@@ -4684,6 +5503,11 @@ private extension ViewController {
|
|
4684
|
5503
|
}
|
|
4685
|
5504
|
scheduleCachedMeetings = []
|
|
4686
|
5505
|
applySchedulePageFiltersAndRender()
|
|
|
5506
|
+ if calendarPageGridStack != nil {
|
|
|
5507
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
5508
|
+ renderCalendarMonthGrid()
|
|
|
5509
|
+ renderCalendarSelectedDay()
|
|
|
5510
|
+ }
|
|
4687
|
5511
|
}
|
|
4688
|
5512
|
return
|
|
4689
|
5513
|
}
|
|
|
@@ -4702,6 +5526,11 @@ private extension ViewController {
|
|
4702
|
5526
|
}
|
|
4703
|
5527
|
scheduleCachedMeetings = meetings
|
|
4704
|
5528
|
applySchedulePageFiltersAndRender()
|
|
|
5529
|
+ if calendarPageGridStack != nil {
|
|
|
5530
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
5531
|
+ renderCalendarMonthGrid()
|
|
|
5532
|
+ renderCalendarSelectedDay()
|
|
|
5533
|
+ }
|
|
4705
|
5534
|
}
|
|
4706
|
5535
|
} catch {
|
|
4707
|
5536
|
await MainActor.run {
|
|
|
@@ -4716,6 +5545,11 @@ private extension ViewController {
|
|
4716
|
5545
|
}
|
|
4717
|
5546
|
scheduleCachedMeetings = []
|
|
4718
|
5547
|
applySchedulePageFiltersAndRender()
|
|
|
5548
|
+ if calendarPageGridStack != nil {
|
|
|
5549
|
+ calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
|
|
|
5550
|
+ renderCalendarMonthGrid()
|
|
|
5551
|
+ renderCalendarSelectedDay()
|
|
|
5552
|
+ }
|
|
4719
|
5553
|
showSimpleError("Couldn’t load schedule.", error: error)
|
|
4720
|
5554
|
}
|
|
4721
|
5555
|
}
|