|
|
@@ -7,6 +7,7 @@
|
|
7
|
7
|
|
|
8
|
8
|
import Cocoa
|
|
9
|
9
|
import WebKit
|
|
|
10
|
+import AuthenticationServices
|
|
10
|
11
|
|
|
11
|
12
|
private enum SidebarPage: Int {
|
|
12
|
13
|
case joinMeetings = 0
|
|
|
@@ -37,6 +38,12 @@ private enum PremiumPlan: Int {
|
|
37
|
38
|
}
|
|
38
|
39
|
|
|
39
|
40
|
final class ViewController: NSViewController {
|
|
|
41
|
+ private struct GoogleProfileDisplay {
|
|
|
42
|
+ let name: String
|
|
|
43
|
+ let email: String
|
|
|
44
|
+ let pictureURL: URL?
|
|
|
45
|
+ }
|
|
|
46
|
+
|
|
40
|
47
|
private var palette = Palette(isDarkMode: true)
|
|
41
|
48
|
private let typography = Typography()
|
|
42
|
49
|
private let launchContentSize = NSSize(width: 920, height: 690)
|
|
|
@@ -61,6 +68,27 @@ final class ViewController: NSViewController {
|
|
61
|
68
|
private weak var meetLinkField: NSTextField?
|
|
62
|
69
|
private weak var browseAddressField: NSTextField?
|
|
63
|
70
|
private var inAppBrowserWindowController: InAppBrowserWindowController?
|
|
|
71
|
+ private let googleOAuth = GoogleOAuthService.shared
|
|
|
72
|
+ private let calendarClient = GoogleCalendarClient()
|
|
|
73
|
+
|
|
|
74
|
+ private enum ScheduleFilter: Int {
|
|
|
75
|
+ case all = 0
|
|
|
76
|
+ case today = 1
|
|
|
77
|
+ case week = 2
|
|
|
78
|
+ }
|
|
|
79
|
+
|
|
|
80
|
+ private var scheduleFilter: ScheduleFilter = .all
|
|
|
81
|
+ private weak var scheduleDateHeadingLabel: NSTextField?
|
|
|
82
|
+ private weak var scheduleCardsStack: NSStackView?
|
|
|
83
|
+ private weak var scheduleCardsScrollView: NSScrollView?
|
|
|
84
|
+ private weak var scheduleScrollLeftButton: NSView?
|
|
|
85
|
+ private weak var scheduleScrollRightButton: NSView?
|
|
|
86
|
+ private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
|
87
|
+ private weak var scheduleGoogleAuthButton: NSButton?
|
|
|
88
|
+ private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
|
|
|
89
|
+ private var scheduleGoogleAuthHovering = false
|
|
|
90
|
+ private var scheduleCurrentProfile: GoogleProfileDisplay?
|
|
|
91
|
+ private var scheduleProfileImageTask: Task<Void, Never>?
|
|
64
|
92
|
|
|
65
|
93
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
66
|
94
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
|
@@ -903,14 +931,24 @@ private extension ViewController {
|
|
903
|
931
|
contentStack.spacing = 14
|
|
904
|
932
|
contentStack.alignment = .leading
|
|
905
|
933
|
|
|
|
934
|
+ contentStack.addArrangedSubview(scheduleTopAuthRow())
|
|
|
935
|
+ if let authRow = contentStack.arrangedSubviews.last {
|
|
|
936
|
+ contentStack.setCustomSpacing(20, after: authRow)
|
|
|
937
|
+ }
|
|
|
938
|
+
|
|
906
|
939
|
let joinActions = meetJoinActionsRow()
|
|
907
|
940
|
contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
|
|
908
|
941
|
contentStack.addArrangedSubview(meetJoinSectionRow())
|
|
909
|
942
|
contentStack.addArrangedSubview(joinActions)
|
|
910
|
943
|
contentStack.setCustomSpacing(26, after: joinActions)
|
|
911
|
944
|
contentStack.addArrangedSubview(scheduleHeader())
|
|
912
|
|
- contentStack.addArrangedSubview(textLabel("Tuesday, 14 Apr", font: typography.dateHeading, color: palette.textSecondary))
|
|
913
|
|
- contentStack.addArrangedSubview(scheduleCardsRow())
|
|
|
945
|
+
|
|
|
946
|
+ let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
|
|
|
947
|
+ scheduleDateHeadingLabel = dateHeading
|
|
|
948
|
+ contentStack.addArrangedSubview(dateHeading)
|
|
|
949
|
+
|
|
|
950
|
+ let cardsRow = scheduleCardsRow(meetings: [])
|
|
|
951
|
+ contentStack.addArrangedSubview(cardsRow)
|
|
914
|
952
|
|
|
915
|
953
|
panel.addSubview(contentStack)
|
|
916
|
954
|
|
|
|
@@ -920,6 +958,10 @@ private extension ViewController {
|
|
920
|
958
|
contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26)
|
|
921
|
959
|
])
|
|
922
|
960
|
|
|
|
961
|
+ Task { [weak self] in
|
|
|
962
|
+ await self?.loadSchedule()
|
|
|
963
|
+ }
|
|
|
964
|
+
|
|
923
|
965
|
return panel
|
|
924
|
966
|
}
|
|
925
|
967
|
|
|
|
@@ -1777,94 +1819,317 @@ private extension ViewController {
|
|
1777
|
1819
|
row.addArrangedSubview(spacer)
|
|
1778
|
1820
|
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
1779
|
1821
|
|
|
1780
|
|
- row.addArrangedSubview(iconRoundButton("?", size: 34))
|
|
1781
|
|
- row.addArrangedSubview(iconRoundButton("⟳", size: 34))
|
|
1782
|
|
-
|
|
1783
|
|
- let filter = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
|
|
1784
|
|
- filter.translatesAutoresizingMaskIntoConstraints = false
|
|
1785
|
|
- filter.widthAnchor.constraint(equalToConstant: 156).isActive = true
|
|
1786
|
|
- filter.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
1787
|
|
- styleSurface(filter, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
1788
|
|
- let filterText = textLabel("All", font: typography.filterText, color: palette.textSecondary)
|
|
1789
|
|
- let arrow = textLabel("▾", font: typography.filterArrow, color: palette.textMuted)
|
|
1790
|
|
- filterText.translatesAutoresizingMaskIntoConstraints = false
|
|
1791
|
|
- arrow.translatesAutoresizingMaskIntoConstraints = false
|
|
1792
|
|
- filter.addSubview(filterText)
|
|
1793
|
|
- filter.addSubview(arrow)
|
|
|
1822
|
+ row.addArrangedSubview(makeScheduleRefreshButton())
|
|
1794
|
1823
|
|
|
1795
|
|
- NSLayoutConstraint.activate([
|
|
1796
|
|
- filterText.leadingAnchor.constraint(equalTo: filter.leadingAnchor, constant: 12),
|
|
1797
|
|
- filterText.centerYAnchor.constraint(equalTo: filter.centerYAnchor),
|
|
1798
|
|
- arrow.trailingAnchor.constraint(equalTo: filter.trailingAnchor, constant: -10),
|
|
1799
|
|
- arrow.centerYAnchor.constraint(equalTo: filter.centerYAnchor)
|
|
1800
|
|
- ])
|
|
1801
|
|
-
|
|
1802
|
|
- row.addArrangedSubview(filter)
|
|
|
1824
|
+ row.addArrangedSubview(makeScheduleFilterDropdown())
|
|
1803
|
1825
|
row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
1804
|
1826
|
return row
|
|
1805
|
1827
|
}
|
|
1806
|
1828
|
|
|
1807
|
|
- func scheduleCardsRow() -> NSView {
|
|
|
1829
|
+ private func scheduleTopAuthRow() -> NSView {
|
|
1808
|
1830
|
let row = NSStackView()
|
|
1809
|
1831
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
1810
|
1832
|
row.orientation = .horizontal
|
|
|
1833
|
+ row.alignment = .centerY
|
|
1811
|
1834
|
row.spacing = 10
|
|
|
1835
|
+
|
|
|
1836
|
+ let spacer = NSView()
|
|
|
1837
|
+ spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1838
|
+ row.addArrangedSubview(spacer)
|
|
|
1839
|
+ spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
1840
|
+
|
|
|
1841
|
+ let authButton = makeGoogleAuthButton()
|
|
|
1842
|
+ scheduleGoogleAuthButton = authButton
|
|
|
1843
|
+ updateGoogleAuthButtonTitle()
|
|
|
1844
|
+ row.addArrangedSubview(authButton)
|
|
|
1845
|
+
|
|
|
1846
|
+ row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
1847
|
+ return row
|
|
|
1848
|
+ }
|
|
|
1849
|
+
|
|
|
1850
|
+ private func makeScheduleFilterDropdown() -> NSPopUpButton {
|
|
|
1851
|
+ let button = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
|
1852
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1853
|
+ button.autoenablesItems = false
|
|
|
1854
|
+ button.wantsLayer = true
|
|
|
1855
|
+ button.layer?.cornerRadius = 8
|
|
|
1856
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
1857
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
1858
|
+ button.layer?.borderWidth = 1
|
|
|
1859
|
+ button.font = typography.filterText
|
|
|
1860
|
+ button.contentTintColor = palette.textSecondary
|
|
|
1861
|
+ button.target = self
|
|
|
1862
|
+ button.action = #selector(scheduleFilterDropdownChanged(_:))
|
|
|
1863
|
+ button.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
1864
|
+ button.widthAnchor.constraint(equalToConstant: 156).isActive = true
|
|
|
1865
|
+
|
|
|
1866
|
+ button.removeAllItems()
|
|
|
1867
|
+ button.addItems(withTitles: ["All", "Today", "This week"])
|
|
|
1868
|
+ button.selectItem(at: scheduleFilter.rawValue)
|
|
|
1869
|
+
|
|
|
1870
|
+ if let menu = button.menu {
|
|
|
1871
|
+ for (index, item) in menu.items.enumerated() {
|
|
|
1872
|
+ item.tag = index
|
|
|
1873
|
+ }
|
|
|
1874
|
+ }
|
|
|
1875
|
+
|
|
|
1876
|
+ scheduleFilterDropdown = button
|
|
|
1877
|
+ return button
|
|
|
1878
|
+ }
|
|
|
1879
|
+
|
|
|
1880
|
+ private func makeSchedulePillButton(title: String) -> NSButton {
|
|
|
1881
|
+ let button = NSButton(title: title, target: nil, action: nil)
|
|
|
1882
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1883
|
+ button.isBordered = false
|
|
|
1884
|
+ button.bezelStyle = .regularSquare
|
|
|
1885
|
+ button.wantsLayer = true
|
|
|
1886
|
+ button.layer?.cornerRadius = 8
|
|
|
1887
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
1888
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
1889
|
+ button.layer?.borderWidth = 1
|
|
|
1890
|
+ button.font = typography.filterText
|
|
|
1891
|
+ button.contentTintColor = palette.textSecondary
|
|
|
1892
|
+ button.setButtonType(.momentaryChange)
|
|
|
1893
|
+ button.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
1894
|
+ button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
|
|
|
1895
|
+ return button
|
|
|
1896
|
+ }
|
|
|
1897
|
+
|
|
|
1898
|
+ private func makeGoogleAuthButton() -> NSButton {
|
|
|
1899
|
+ let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
|
|
|
1900
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1901
|
+ button.isBordered = false
|
|
|
1902
|
+ button.bezelStyle = .regularSquare
|
|
|
1903
|
+ button.wantsLayer = true
|
|
|
1904
|
+ button.layer?.cornerRadius = 21
|
|
|
1905
|
+ button.layer?.borderWidth = 1
|
|
|
1906
|
+ button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
|
|
|
1907
|
+ button.imagePosition = .imageLeading
|
|
|
1908
|
+ button.alignment = .center
|
|
|
1909
|
+ button.imageHugsTitle = true
|
|
|
1910
|
+ button.lineBreakMode = .byTruncatingTail
|
|
|
1911
|
+ button.contentTintColor = palette.textPrimary
|
|
|
1912
|
+ button.imageScaling = .scaleNone
|
|
|
1913
|
+ button.heightAnchor.constraint(equalToConstant: 42).isActive = true
|
|
|
1914
|
+ let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
|
|
|
1915
|
+ widthConstraint.isActive = true
|
|
|
1916
|
+ scheduleGoogleAuthButtonWidthConstraint = widthConstraint
|
|
|
1917
|
+ button.onHoverChanged = { [weak self] hovering in
|
|
|
1918
|
+ self?.scheduleGoogleAuthHovering = hovering
|
|
|
1919
|
+ self?.applyGoogleAuthButtonSurface()
|
|
|
1920
|
+ }
|
|
|
1921
|
+ button.onHoverChanged?(false)
|
|
|
1922
|
+ return button
|
|
|
1923
|
+ }
|
|
|
1924
|
+
|
|
|
1925
|
+ private func makeScheduleRefreshButton() -> NSButton {
|
|
|
1926
|
+ let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
|
|
|
1927
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1928
|
+ button.isBordered = false
|
|
|
1929
|
+ button.bezelStyle = .regularSquare
|
|
|
1930
|
+ button.wantsLayer = true
|
|
|
1931
|
+ button.layer?.cornerRadius = 21
|
|
|
1932
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
1933
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
1934
|
+ button.layer?.borderWidth = 1
|
|
|
1935
|
+ button.setButtonType(.momentaryChange)
|
|
|
1936
|
+ button.contentTintColor = palette.textSecondary
|
|
|
1937
|
+ button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
|
|
|
1938
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
|
|
1939
|
+ button.imagePosition = .imageOnly
|
|
|
1940
|
+ button.imageScaling = .scaleProportionallyDown
|
|
|
1941
|
+ button.focusRingType = .none
|
|
|
1942
|
+ button.heightAnchor.constraint(equalToConstant: 42).isActive = true
|
|
|
1943
|
+ button.widthAnchor.constraint(equalToConstant: 42).isActive = true
|
|
|
1944
|
+ return button
|
|
|
1945
|
+ }
|
|
|
1946
|
+
|
|
|
1947
|
+ func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
|
|
|
1948
|
+ let cardWidth: CGFloat = 240
|
|
|
1949
|
+ let cardsPerViewport: CGFloat = 3
|
|
|
1950
|
+ let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
|
|
|
1951
|
+
|
|
|
1952
|
+ let wrapper = NSStackView()
|
|
|
1953
|
+ wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1954
|
+ wrapper.orientation = .horizontal
|
|
|
1955
|
+ wrapper.alignment = .centerY
|
|
|
1956
|
+ wrapper.spacing = 10
|
|
|
1957
|
+ let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
|
|
|
1958
|
+ scheduleScrollLeftButton = leftButton
|
|
|
1959
|
+ wrapper.addArrangedSubview(leftButton)
|
|
|
1960
|
+
|
|
|
1961
|
+ let scroll = NSScrollView()
|
|
|
1962
|
+ scheduleCardsScrollView = scroll
|
|
|
1963
|
+ scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1964
|
+ scroll.drawsBackground = false
|
|
|
1965
|
+ scroll.hasHorizontalScroller = false
|
|
|
1966
|
+ scroll.hasVerticalScroller = false
|
|
|
1967
|
+ scroll.horizontalScrollElasticity = .allowed
|
|
|
1968
|
+ scroll.verticalScrollElasticity = .none
|
|
|
1969
|
+ scroll.autohidesScrollers = false
|
|
|
1970
|
+ scroll.borderType = .noBorder
|
|
|
1971
|
+ scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
1972
|
+ scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
|
|
|
1973
|
+
|
|
|
1974
|
+ let row = NSStackView()
|
|
|
1975
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1976
|
+ row.orientation = .horizontal
|
|
|
1977
|
+ row.spacing = 12
|
|
1812
|
1978
|
row.alignment = .top
|
|
1813
|
|
- row.distribution = .fill
|
|
|
1979
|
+ row.distribution = .gravityAreas
|
|
1814
|
1980
|
row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
1815
|
|
- row.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
1981
|
+ row.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
1982
|
+ scheduleCardsStack = row
|
|
1816
|
1983
|
|
|
1817
|
|
- row.addArrangedSubview(scheduleCard())
|
|
1818
|
|
- row.addArrangedSubview(scheduleCard())
|
|
1819
|
|
- return row
|
|
|
1984
|
+ scroll.documentView = row
|
|
|
1985
|
+ scroll.contentView.postsBoundsChangedNotifications = true
|
|
|
1986
|
+
|
|
|
1987
|
+ // Ensure the stack view determines content size for horizontal scrolling.
|
|
|
1988
|
+ NSLayoutConstraint.activate([
|
|
|
1989
|
+ row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
|
|
1990
|
+ row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
|
|
|
1991
|
+ row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
|
|
|
1992
|
+ row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
|
|
|
1993
|
+ row.heightAnchor.constraint(equalToConstant: 150)
|
|
|
1994
|
+ ])
|
|
|
1995
|
+
|
|
|
1996
|
+ renderScheduleCards(into: row, meetings: meetings)
|
|
|
1997
|
+ wrapper.addArrangedSubview(scroll)
|
|
|
1998
|
+ let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
|
|
|
1999
|
+ scheduleScrollRightButton = rightButton
|
|
|
2000
|
+ wrapper.addArrangedSubview(rightButton)
|
|
|
2001
|
+ scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
2002
|
+ scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
2003
|
+ wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
2004
|
+ return wrapper
|
|
1820
|
2005
|
}
|
|
1821
|
2006
|
|
|
1822
|
|
- func scheduleCard() -> NSView {
|
|
1823
|
|
- let cardWidth: CGFloat = 264
|
|
|
2007
|
+ func scheduleCard(meeting: ScheduledMeeting) -> NSView {
|
|
|
2008
|
+ let cardWidth: CGFloat = 240
|
|
1824
|
2009
|
|
|
1825
|
|
- let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
|
2010
|
+ let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
|
|
1826
|
2011
|
styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
|
|
1827
|
2012
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
1828
|
2013
|
card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
|
|
1829
|
|
- card.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
2014
|
+ card.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
2015
|
+ card.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
2016
|
+ card.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
1830
|
2017
|
|
|
1831
|
|
- let icon = roundedContainer(cornerRadius: 5, color: palette.meetingBadge)
|
|
|
2018
|
+ let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
|
|
1832
|
2019
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
1833
|
|
- icon.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
|
1834
|
|
- icon.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
1835
|
|
- let iconText = textLabel("••", font: typography.cardIcon, color: .white)
|
|
1836
|
|
- iconText.translatesAutoresizingMaskIntoConstraints = false
|
|
1837
|
|
- icon.addSubview(iconText)
|
|
|
2020
|
+ icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
2021
|
+ icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
2022
|
+ let iconView = NSImageView()
|
|
|
2023
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2024
|
+ iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
|
|
|
2025
|
+ iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
|
|
2026
|
+ iconView.contentTintColor = .white
|
|
|
2027
|
+ icon.addSubview(iconView)
|
|
1838
|
2028
|
NSLayoutConstraint.activate([
|
|
1839
|
|
- iconText.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
|
|
1840
|
|
- iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
|
|
|
2029
|
+ iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
|
|
|
2030
|
+ iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
|
|
1841
|
2031
|
])
|
|
1842
|
2032
|
|
|
1843
|
|
- let title = textLabel("General Meeting", font: typography.cardTitle, color: palette.textPrimary)
|
|
1844
|
|
- let subtitle = textLabel("Baisakhi", font: typography.cardSubtitle, color: palette.textPrimary)
|
|
1845
|
|
- let time = textLabel("12:00 AM - 11:59 PM", font: typography.cardTime, color: palette.textSecondary)
|
|
|
2033
|
+ let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
|
|
|
2034
|
+ title.lineBreakMode = .byTruncatingTail
|
|
|
2035
|
+ title.maximumNumberOfLines = 1
|
|
|
2036
|
+ title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
2037
|
+ let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
|
|
|
2038
|
+ let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
|
|
|
2039
|
+ let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
|
|
|
2040
|
+ let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
|
|
|
2041
|
+ dayChip.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2042
|
+ dayChip.layer?.borderWidth = 1
|
|
|
2043
|
+ dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
|
|
|
2044
|
+ let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
|
|
|
2045
|
+ dayText.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2046
|
+ dayChip.addSubview(dayText)
|
|
|
2047
|
+ NSLayoutConstraint.activate([
|
|
|
2048
|
+ dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
|
|
|
2049
|
+ dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
|
|
|
2050
|
+ dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
|
|
|
2051
|
+ dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
|
|
|
2052
|
+ ])
|
|
1846
|
2053
|
|
|
1847
|
2054
|
card.addSubview(icon)
|
|
|
2055
|
+ card.addSubview(dayChip)
|
|
1848
|
2056
|
card.addSubview(title)
|
|
1849
|
2057
|
card.addSubview(subtitle)
|
|
1850
|
2058
|
card.addSubview(time)
|
|
|
2059
|
+ card.addSubview(duration)
|
|
1851
|
2060
|
|
|
1852
|
2061
|
NSLayoutConstraint.activate([
|
|
1853
|
2062
|
icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
1854
|
2063
|
icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
|
|
1855
|
2064
|
|
|
|
2065
|
+ dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
|
|
|
2066
|
+ dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
|
|
|
2067
|
+
|
|
1856
|
2068
|
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
|
|
1857
|
2069
|
title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
|
|
1858
|
|
- title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
|
2070
|
+ title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
|
|
|
2071
|
+ title.widthAnchor.constraint(lessThanOrEqualToConstant: 130),
|
|
1859
|
2072
|
|
|
1860
|
2073
|
subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
1861
|
|
- subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 7),
|
|
|
2074
|
+ subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
|
|
|
2075
|
+ subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
1862
|
2076
|
|
|
1863
|
2077
|
time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
1864
|
|
- time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
|
|
|
2078
|
+ time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
|
|
|
2079
|
+ time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
|
2080
|
+
|
|
|
2081
|
+ duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
|
2082
|
+ duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
|
|
|
2083
|
+ duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
|
|
1865
|
2084
|
])
|
|
1866
|
2085
|
|
|
1867
|
|
- return card
|
|
|
2086
|
+ let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
|
|
|
2087
|
+ hit.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2088
|
+ hit.isBordered = false
|
|
|
2089
|
+ hit.bezelStyle = .regularSquare
|
|
|
2090
|
+ hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
|
|
|
2091
|
+ hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
|
|
|
2092
|
+ hit.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
2093
|
+ hit.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
2094
|
+ hit.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
2095
|
+ hit.addSubview(card)
|
|
|
2096
|
+ NSLayoutConstraint.activate([
|
|
|
2097
|
+ card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
|
|
|
2098
|
+ card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
|
|
|
2099
|
+ card.topAnchor.constraint(equalTo: hit.topAnchor),
|
|
|
2100
|
+ card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
|
|
|
2101
|
+ ])
|
|
|
2102
|
+ hit.onHoverChanged = { [weak self] hovering in
|
|
|
2103
|
+ guard let self else { return }
|
|
|
2104
|
+ let base = self.palette.sectionCard
|
|
|
2105
|
+ let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
|
|
|
2106
|
+ let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
|
|
|
2107
|
+ card.layer?.backgroundColor = (hovering ? hover : base).cgColor
|
|
|
2108
|
+ }
|
|
|
2109
|
+ hit.onHoverChanged?(false)
|
|
|
2110
|
+
|
|
|
2111
|
+ return hit
|
|
|
2112
|
+ }
|
|
|
2113
|
+
|
|
|
2114
|
+ private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
|
|
|
2115
|
+ let button = NSButton(title: "", target: self, action: action)
|
|
|
2116
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2117
|
+ button.isBordered = false
|
|
|
2118
|
+ button.bezelStyle = .regularSquare
|
|
|
2119
|
+ button.wantsLayer = true
|
|
|
2120
|
+ button.layer?.cornerRadius = 16
|
|
|
2121
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
2122
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
2123
|
+ button.layer?.borderWidth = 1
|
|
|
2124
|
+ button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
|
|
|
2125
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
|
|
|
2126
|
+ button.imagePosition = .imageOnly
|
|
|
2127
|
+ button.imageScaling = .scaleProportionallyDown
|
|
|
2128
|
+ button.contentTintColor = palette.textSecondary
|
|
|
2129
|
+ button.focusRingType = .none
|
|
|
2130
|
+ button.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
|
|
2131
|
+ button.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
|
|
2132
|
+ return button
|
|
1868
|
2133
|
}
|
|
1869
|
2134
|
}
|
|
1870
|
2135
|
|
|
|
@@ -2375,7 +2640,7 @@ private extension ViewController {
|
|
2375
|
2640
|
return button
|
|
2376
|
2641
|
}
|
|
2377
|
2642
|
|
|
2378
|
|
- func iconRoundButton(_ symbol: String, size: CGFloat) -> NSView {
|
|
|
2643
|
+ func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
|
|
2379
|
2644
|
let button = HoverTrackingView()
|
|
2380
|
2645
|
button.wantsLayer = true
|
|
2381
|
2646
|
button.layer?.cornerRadius = size / 2
|
|
|
@@ -2385,11 +2650,16 @@ private extension ViewController {
|
|
2385
|
2650
|
button.heightAnchor.constraint(equalToConstant: size).isActive = true
|
|
2386
|
2651
|
styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
2387
|
2652
|
|
|
2388
|
|
- let label = textLabel(symbol, font: typography.iconButton, color: palette.textSecondary)
|
|
2389
|
|
- button.addSubview(label)
|
|
|
2653
|
+ let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
|
|
|
2654
|
+ let iconView = NSImageView()
|
|
|
2655
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2656
|
+ iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
|
|
|
2657
|
+ iconView.symbolConfiguration = symbolConfig
|
|
|
2658
|
+ iconView.contentTintColor = palette.textSecondary
|
|
|
2659
|
+ button.addSubview(iconView)
|
|
2390
|
2660
|
NSLayoutConstraint.activate([
|
|
2391
|
|
- label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
|
|
2392
|
|
- label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
|
|
|
2661
|
+ iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
|
|
|
2662
|
+ iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
|
|
2393
|
2663
|
])
|
|
2394
|
2664
|
|
|
2395
|
2665
|
let baseColor = palette.inputBackground
|
|
|
@@ -2399,11 +2669,456 @@ private extension ViewController {
|
|
2399
|
2669
|
button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
2400
|
2670
|
}
|
|
2401
|
2671
|
button.onHoverChanged?(false)
|
|
|
2672
|
+ button.onClick = onClick
|
|
2402
|
2673
|
|
|
2403
|
2674
|
return button
|
|
2404
|
2675
|
}
|
|
2405
|
2676
|
}
|
|
2406
|
2677
|
|
|
|
2678
|
+// MARK: - Schedule actions (OAuth entry)
|
|
|
2679
|
+
|
|
|
2680
|
+private extension ViewController {
|
|
|
2681
|
+ @objc func scheduleReloadButtonPressed(_ sender: NSButton) {
|
|
|
2682
|
+ scheduleReloadClicked()
|
|
|
2683
|
+ }
|
|
|
2684
|
+
|
|
|
2685
|
+ @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
|
|
|
2686
|
+ scrollScheduleCards(direction: -1)
|
|
|
2687
|
+ }
|
|
|
2688
|
+
|
|
|
2689
|
+ @objc func scheduleScrollRightPressed(_ sender: NSButton) {
|
|
|
2690
|
+ scrollScheduleCards(direction: 1)
|
|
|
2691
|
+ }
|
|
|
2692
|
+
|
|
|
2693
|
+ @objc func scheduleCardButtonPressed(_ sender: NSButton) {
|
|
|
2694
|
+ guard let raw = sender.identifier?.rawValue,
|
|
|
2695
|
+ let url = URL(string: raw) else { return }
|
|
|
2696
|
+ openMeetingURL(url)
|
|
|
2697
|
+ }
|
|
|
2698
|
+
|
|
|
2699
|
+ @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
|
|
|
2700
|
+ scheduleConnectClicked()
|
|
|
2701
|
+ }
|
|
|
2702
|
+
|
|
|
2703
|
+ private func scheduleInitialHeadingText() -> String {
|
|
|
2704
|
+ googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
|
|
|
2705
|
+ }
|
|
|
2706
|
+
|
|
|
2707
|
+ @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
|
|
|
2708
|
+ guard let selectedItem = sender.selectedItem,
|
|
|
2709
|
+ let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
|
|
|
2710
|
+ applyScheduleFilter(filter)
|
|
|
2711
|
+ }
|
|
|
2712
|
+
|
|
|
2713
|
+ private func applyScheduleFilter(_ filter: ScheduleFilter) {
|
|
|
2714
|
+ scheduleFilter = filter
|
|
|
2715
|
+ scheduleFilterDropdown?.selectItem(at: filter.rawValue)
|
|
|
2716
|
+ Task { [weak self] in
|
|
|
2717
|
+ await self?.loadSchedule()
|
|
|
2718
|
+ }
|
|
|
2719
|
+ }
|
|
|
2720
|
+
|
|
|
2721
|
+ private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
|
|
|
2722
|
+ if meeting.isAllDay { return "All day" }
|
|
|
2723
|
+ let f = DateFormatter()
|
|
|
2724
|
+ f.locale = Locale.current
|
|
|
2725
|
+ f.timeZone = TimeZone.current
|
|
|
2726
|
+ f.dateStyle = .none
|
|
|
2727
|
+ f.timeStyle = .short
|
|
|
2728
|
+ return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
|
|
|
2729
|
+ }
|
|
|
2730
|
+
|
|
|
2731
|
+ private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
|
|
|
2732
|
+ let f = DateFormatter()
|
|
|
2733
|
+ f.locale = Locale.current
|
|
|
2734
|
+ f.timeZone = TimeZone.current
|
|
|
2735
|
+ f.dateFormat = "EEE, d MMM"
|
|
|
2736
|
+ return f.string(from: meeting.startDate)
|
|
|
2737
|
+ }
|
|
|
2738
|
+
|
|
|
2739
|
+ private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
|
|
|
2740
|
+ if meeting.isAllDay { return "Duration: all day" }
|
|
|
2741
|
+ let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
|
|
|
2742
|
+ let totalMinutes = Int(duration / 60)
|
|
|
2743
|
+ let hours = totalMinutes / 60
|
|
|
2744
|
+ let minutes = totalMinutes % 60
|
|
|
2745
|
+ if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
|
|
|
2746
|
+ if hours > 0 { return "Duration: \(hours)h" }
|
|
|
2747
|
+ return "Duration: \(minutes)m"
|
|
|
2748
|
+ }
|
|
|
2749
|
+
|
|
|
2750
|
+ private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
|
|
|
2751
|
+ guard let first = meetings.first else {
|
|
|
2752
|
+ return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
|
|
|
2753
|
+ }
|
|
|
2754
|
+
|
|
|
2755
|
+ let day = Calendar.current.startOfDay(for: first.startDate)
|
|
|
2756
|
+ let f = DateFormatter()
|
|
|
2757
|
+ f.locale = Locale.current
|
|
|
2758
|
+ f.timeZone = TimeZone.current
|
|
|
2759
|
+ f.dateFormat = "EEEE, d MMM"
|
|
|
2760
|
+ return f.string(from: day)
|
|
|
2761
|
+ }
|
|
|
2762
|
+
|
|
|
2763
|
+ private func openMeetingURL(_ url: URL) {
|
|
|
2764
|
+ NSWorkspace.shared.open(url)
|
|
|
2765
|
+ }
|
|
|
2766
|
+
|
|
|
2767
|
+ private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
|
|
|
2768
|
+ let shouldShowScrollControls = meetings.count > 3
|
|
|
2769
|
+ scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
|
|
|
2770
|
+ scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
|
|
|
2771
|
+ scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
|
|
|
2772
|
+ if let scroll = scheduleCardsScrollView {
|
|
|
2773
|
+ scroll.reflectScrolledClipView(scroll.contentView)
|
|
|
2774
|
+ }
|
|
|
2775
|
+
|
|
|
2776
|
+ stack.arrangedSubviews.forEach { v in
|
|
|
2777
|
+ stack.removeArrangedSubview(v)
|
|
|
2778
|
+ v.removeFromSuperview()
|
|
|
2779
|
+ }
|
|
|
2780
|
+
|
|
|
2781
|
+ if meetings.isEmpty {
|
|
|
2782
|
+ let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
|
2783
|
+ empty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2784
|
+ empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
|
|
|
2785
|
+ empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
2786
|
+ styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
2787
|
+
|
|
|
2788
|
+ let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
|
|
|
2789
|
+ label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2790
|
+ empty.addSubview(label)
|
|
|
2791
|
+ NSLayoutConstraint.activate([
|
|
|
2792
|
+ label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
|
|
|
2793
|
+ label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
|
|
|
2794
|
+ ])
|
|
|
2795
|
+ stack.addArrangedSubview(empty)
|
|
|
2796
|
+ return
|
|
|
2797
|
+ }
|
|
|
2798
|
+
|
|
|
2799
|
+ for meeting in meetings {
|
|
|
2800
|
+ stack.addArrangedSubview(scheduleCard(meeting: meeting))
|
|
|
2801
|
+ }
|
|
|
2802
|
+ }
|
|
|
2803
|
+
|
|
|
2804
|
+ private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
|
|
|
2805
|
+ switch scheduleFilter {
|
|
|
2806
|
+ case .all:
|
|
|
2807
|
+ return meetings
|
|
|
2808
|
+ case .today:
|
|
|
2809
|
+ let start = Calendar.current.startOfDay(for: Date())
|
|
|
2810
|
+ let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
|
|
|
2811
|
+ return meetings.filter { $0.startDate >= start && $0.startDate < end }
|
|
|
2812
|
+ case .week:
|
|
|
2813
|
+ let now = Date()
|
|
|
2814
|
+ let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
|
|
|
2815
|
+ return meetings.filter { $0.startDate >= now && $0.startDate <= end }
|
|
|
2816
|
+ }
|
|
|
2817
|
+ }
|
|
|
2818
|
+
|
|
|
2819
|
+ private func scrollScheduleCards(direction: Int) {
|
|
|
2820
|
+ guard let scroll = scheduleCardsScrollView else { return }
|
|
|
2821
|
+ let contentBounds = scroll.contentView.bounds
|
|
|
2822
|
+ let step = max(220, contentBounds.width * 0.7)
|
|
|
2823
|
+ let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
|
|
|
2824
|
+ let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
|
|
|
2825
|
+ let nextX = min(max(0, proposedX), maxX)
|
|
|
2826
|
+ scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
|
|
|
2827
|
+ scroll.reflectScrolledClipView(scroll.contentView)
|
|
|
2828
|
+ }
|
|
|
2829
|
+
|
|
|
2830
|
+ private func loadSchedule() async {
|
|
|
2831
|
+ do {
|
|
|
2832
|
+ if googleOAuth.loadTokens() == nil {
|
|
|
2833
|
+ await MainActor.run {
|
|
|
2834
|
+ updateGoogleAuthButtonTitle()
|
|
|
2835
|
+ applyGoogleProfile(nil)
|
|
|
2836
|
+ scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
2837
|
+ if let stack = scheduleCardsStack {
|
|
|
2838
|
+ renderScheduleCards(into: stack, meetings: [])
|
|
|
2839
|
+ }
|
|
|
2840
|
+ }
|
|
|
2841
|
+ return
|
|
|
2842
|
+ }
|
|
|
2843
|
+
|
|
|
2844
|
+ let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
2845
|
+ let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
2846
|
+ let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
|
|
|
2847
|
+ let filtered = filteredMeetings(meetings)
|
|
|
2848
|
+
|
|
|
2849
|
+ await MainActor.run {
|
|
|
2850
|
+ updateGoogleAuthButtonTitle()
|
|
|
2851
|
+ applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
|
2852
|
+ scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
|
|
|
2853
|
+ if let stack = scheduleCardsStack {
|
|
|
2854
|
+ renderScheduleCards(into: stack, meetings: filtered)
|
|
|
2855
|
+ }
|
|
|
2856
|
+ }
|
|
|
2857
|
+ } catch {
|
|
|
2858
|
+ await MainActor.run {
|
|
|
2859
|
+ updateGoogleAuthButtonTitle()
|
|
|
2860
|
+ if googleOAuth.loadTokens() == nil {
|
|
|
2861
|
+ applyGoogleProfile(nil)
|
|
|
2862
|
+ }
|
|
|
2863
|
+ scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
|
2864
|
+ if let stack = scheduleCardsStack {
|
|
|
2865
|
+ renderScheduleCards(into: stack, meetings: [])
|
|
|
2866
|
+ }
|
|
|
2867
|
+ showSimpleError("Couldn’t load schedule.", error: error)
|
|
|
2868
|
+ }
|
|
|
2869
|
+ }
|
|
|
2870
|
+ }
|
|
|
2871
|
+
|
|
|
2872
|
+ func showScheduleHelp() {
|
|
|
2873
|
+ let alert = NSAlert()
|
|
|
2874
|
+ alert.messageText = "Google schedule"
|
|
|
2875
|
+ alert.informativeText = "To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
|
|
|
2876
|
+ alert.addButton(withTitle: "OK")
|
|
|
2877
|
+ alert.runModal()
|
|
|
2878
|
+ }
|
|
|
2879
|
+
|
|
|
2880
|
+ func scheduleReloadClicked() {
|
|
|
2881
|
+ Task { [weak self] in
|
|
|
2882
|
+ guard let self else { return }
|
|
|
2883
|
+ do {
|
|
|
2884
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
2885
|
+ _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
2886
|
+ await MainActor.run {
|
|
|
2887
|
+ self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
|
|
|
2888
|
+ self.pageCache[.joinMeetings] = nil
|
|
|
2889
|
+ self.showSidebarPage(.joinMeetings)
|
|
|
2890
|
+ }
|
|
|
2891
|
+ await self.loadSchedule()
|
|
|
2892
|
+ } catch {
|
|
|
2893
|
+ await MainActor.run {
|
|
|
2894
|
+ self.showSimpleError("Couldn’t refresh schedule.", error: error)
|
|
|
2895
|
+ }
|
|
|
2896
|
+ }
|
|
|
2897
|
+ }
|
|
|
2898
|
+ }
|
|
|
2899
|
+
|
|
|
2900
|
+ func scheduleConnectClicked() {
|
|
|
2901
|
+ Task { [weak self] in
|
|
|
2902
|
+ guard let self else { return }
|
|
|
2903
|
+ do {
|
|
|
2904
|
+ if self.googleOAuth.loadTokens() != nil {
|
|
|
2905
|
+ await MainActor.run { self.showGoogleAccountMenu() }
|
|
|
2906
|
+ return
|
|
|
2907
|
+ }
|
|
|
2908
|
+
|
|
|
2909
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
2910
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
2911
|
+ let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
2912
|
+ await MainActor.run {
|
|
|
2913
|
+ self.updateGoogleAuthButtonTitle()
|
|
|
2914
|
+ self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
|
2915
|
+ self.pageCache[.joinMeetings] = nil
|
|
|
2916
|
+ self.showSidebarPage(.joinMeetings)
|
|
|
2917
|
+ }
|
|
|
2918
|
+ } catch {
|
|
|
2919
|
+ self.showSimpleError("Couldn’t connect Google account.", error: error)
|
|
|
2920
|
+ }
|
|
|
2921
|
+ }
|
|
|
2922
|
+ }
|
|
|
2923
|
+
|
|
|
2924
|
+ private func showGoogleAccountMenu() {
|
|
|
2925
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
2926
|
+ let menu = NSMenu()
|
|
|
2927
|
+
|
|
|
2928
|
+ let name = scheduleCurrentProfile?.name ?? "Google account"
|
|
|
2929
|
+ let email = scheduleCurrentProfile?.email ?? "Signed in"
|
|
|
2930
|
+ let accountItem = NSMenuItem(title: "\(name) (\(email))", action: nil, keyEquivalent: "")
|
|
|
2931
|
+ accountItem.isEnabled = false
|
|
|
2932
|
+ menu.addItem(accountItem)
|
|
|
2933
|
+ menu.addItem(.separator())
|
|
|
2934
|
+
|
|
|
2935
|
+ let logoutItem = NSMenuItem(title: "Logout", action: #selector(scheduleLogoutSelected(_:)), keyEquivalent: "")
|
|
|
2936
|
+ logoutItem.target = self
|
|
|
2937
|
+ menu.addItem(logoutItem)
|
|
|
2938
|
+
|
|
|
2939
|
+ let point = NSPoint(x: 0, y: button.bounds.height + 2)
|
|
|
2940
|
+ menu.popUp(positioning: nil, at: point, in: button)
|
|
|
2941
|
+ }
|
|
|
2942
|
+
|
|
|
2943
|
+ @objc private func scheduleLogoutSelected(_ sender: NSMenuItem) {
|
|
|
2944
|
+ do {
|
|
|
2945
|
+ try googleOAuth.signOut()
|
|
|
2946
|
+ updateGoogleAuthButtonTitle()
|
|
|
2947
|
+ applyGoogleProfile(nil)
|
|
|
2948
|
+ scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
2949
|
+ if let stack = scheduleCardsStack {
|
|
|
2950
|
+ renderScheduleCards(into: stack, meetings: [])
|
|
|
2951
|
+ }
|
|
|
2952
|
+ } catch {
|
|
|
2953
|
+ showSimpleError("Couldn’t logout Google account.", error: error)
|
|
|
2954
|
+ }
|
|
|
2955
|
+ }
|
|
|
2956
|
+
|
|
|
2957
|
+ private func updateGoogleAuthButtonTitle() {
|
|
|
2958
|
+ let signedIn = (googleOAuth.loadTokens() != nil)
|
|
|
2959
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
2960
|
+
|
|
|
2961
|
+ let profileName = scheduleCurrentProfile?.name ?? "Google account"
|
|
|
2962
|
+ let profileEmail = scheduleCurrentProfile?.email ?? "Sign in with Google"
|
|
|
2963
|
+ let title = signedIn ? "\(profileName) · \(profileEmail)" : "Sign in with Google"
|
|
|
2964
|
+ let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
|
|
|
2965
|
+ let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
|
|
|
2966
|
+ button.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
|
2967
|
+ .font: titleFont,
|
|
|
2968
|
+ .foregroundColor: titleColor
|
|
|
2969
|
+ ])
|
|
|
2970
|
+ let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
|
|
|
2971
|
+ let idealWidth = ceil(textWidth + 80) // icon + spacing + side padding
|
|
|
2972
|
+ scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
|
|
|
2973
|
+
|
|
|
2974
|
+ if signedIn {
|
|
|
2975
|
+ let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
2976
|
+ button.image = symbol.flatMap { paddedTrailingImage($0, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8) }
|
|
|
2977
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
|
|
2978
|
+ button.contentTintColor = palette.textPrimary
|
|
|
2979
|
+ } else {
|
|
|
2980
|
+ if let g = NSImage(named: "GoogleGLogo") {
|
|
|
2981
|
+ button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
|
|
|
2982
|
+ } else {
|
|
|
2983
|
+ button.image = nil
|
|
|
2984
|
+ }
|
|
|
2985
|
+ button.contentTintColor = nil
|
|
|
2986
|
+ }
|
|
|
2987
|
+ button.contentTintColor = signedIn ? palette.textPrimary : nil
|
|
|
2988
|
+
|
|
|
2989
|
+ applyGoogleAuthButtonSurface()
|
|
|
2990
|
+ }
|
|
|
2991
|
+
|
|
|
2992
|
+ private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
|
|
|
2993
|
+ let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
2994
|
+ let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
2995
|
+ return GoogleProfileDisplay(
|
|
|
2996
|
+ name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
|
|
|
2997
|
+ email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
|
|
|
2998
|
+ pictureURL: profile.picture.flatMap(URL.init(string:))
|
|
|
2999
|
+ )
|
|
|
3000
|
+ }
|
|
|
3001
|
+
|
|
|
3002
|
+ private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
|
3003
|
+ scheduleProfileImageTask?.cancel()
|
|
|
3004
|
+ scheduleProfileImageTask = nil
|
|
|
3005
|
+ scheduleCurrentProfile = profile
|
|
|
3006
|
+
|
|
|
3007
|
+ updateGoogleAuthButtonTitle()
|
|
|
3008
|
+
|
|
|
3009
|
+ guard let profile, let pictureURL = profile.pictureURL else { return }
|
|
|
3010
|
+ scheduleProfileImageTask = Task { [weak self] in
|
|
|
3011
|
+ do {
|
|
|
3012
|
+ let (data, _) = try await URLSession.shared.data(from: pictureURL)
|
|
|
3013
|
+ if Task.isCancelled { return }
|
|
|
3014
|
+ guard let image = NSImage(data: data) else { return }
|
|
|
3015
|
+ await MainActor.run {
|
|
|
3016
|
+ self?.scheduleGoogleAuthButton?.image = self?.paddedTrailingImage(image, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
|
|
|
3017
|
+ self?.scheduleGoogleAuthButton?.contentTintColor = nil
|
|
|
3018
|
+ }
|
|
|
3019
|
+ } catch {
|
|
|
3020
|
+ // Keep placeholder avatar if image fetch fails.
|
|
|
3021
|
+ }
|
|
|
3022
|
+ }
|
|
|
3023
|
+ }
|
|
|
3024
|
+
|
|
|
3025
|
+ private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
|
|
|
3026
|
+ let result = NSImage(size: size)
|
|
|
3027
|
+ result.lockFocus()
|
|
|
3028
|
+ image.draw(in: NSRect(origin: .zero, size: size),
|
|
|
3029
|
+ from: NSRect(origin: .zero, size: image.size),
|
|
|
3030
|
+ operation: .copy,
|
|
|
3031
|
+ fraction: 1.0)
|
|
|
3032
|
+ result.unlockFocus()
|
|
|
3033
|
+ result.isTemplate = false
|
|
|
3034
|
+ return result
|
|
|
3035
|
+ }
|
|
|
3036
|
+
|
|
|
3037
|
+ private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
|
|
|
3038
|
+ let base = resizedImage(image, to: iconSize)
|
|
|
3039
|
+ let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
|
|
|
3040
|
+ let result = NSImage(size: canvas)
|
|
|
3041
|
+ result.lockFocus()
|
|
|
3042
|
+ base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
|
|
|
3043
|
+ from: NSRect(origin: .zero, size: base.size),
|
|
|
3044
|
+ operation: .copy,
|
|
|
3045
|
+ fraction: 1.0)
|
|
|
3046
|
+ result.unlockFocus()
|
|
|
3047
|
+ result.isTemplate = false
|
|
|
3048
|
+ return result
|
|
|
3049
|
+ }
|
|
|
3050
|
+
|
|
|
3051
|
+ private func applyGoogleAuthButtonSurface() {
|
|
|
3052
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
3053
|
+ let isDark = darkModeEnabled
|
|
|
3054
|
+ let baseBackground = isDark
|
|
|
3055
|
+ ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
|
|
|
3056
|
+ : NSColor.white
|
|
|
3057
|
+ let hoverBlend = isDark ? NSColor.white : NSColor.black
|
|
|
3058
|
+ let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
|
|
|
3059
|
+ let baseBorder = isDark
|
|
|
3060
|
+ ? NSColor(calibratedWhite: 0.50, alpha: 1)
|
|
|
3061
|
+ : NSColor(calibratedWhite: 0.72, alpha: 1)
|
|
|
3062
|
+ let hoverBorder = isDark
|
|
|
3063
|
+ ? NSColor(calibratedWhite: 0.62, alpha: 1)
|
|
|
3064
|
+ : NSColor(calibratedWhite: 0.56, alpha: 1)
|
|
|
3065
|
+ button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
|
|
|
3066
|
+ button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
|
|
|
3067
|
+ }
|
|
|
3068
|
+
|
|
|
3069
|
+ @MainActor
|
|
|
3070
|
+ func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
|
|
|
3071
|
+ if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
|
|
|
3072
|
+
|
|
|
3073
|
+ let alert = NSAlert()
|
|
|
3074
|
+ alert.messageText = "Enter Google OAuth credentials"
|
|
|
3075
|
+ alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
|
|
|
3076
|
+
|
|
|
3077
|
+ let accessory = NSStackView()
|
|
|
3078
|
+ accessory.orientation = .vertical
|
|
|
3079
|
+ accessory.spacing = 8
|
|
|
3080
|
+ accessory.alignment = .leading
|
|
|
3081
|
+
|
|
|
3082
|
+ let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
|
|
|
3083
|
+ idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
|
|
|
3084
|
+ idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
|
|
|
3085
|
+
|
|
|
3086
|
+ let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
|
|
|
3087
|
+ secretField.placeholderString = "Client Secret (GOCSPX-...)"
|
|
|
3088
|
+ secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
|
|
|
3089
|
+
|
|
|
3090
|
+ accessory.addArrangedSubview(idField)
|
|
|
3091
|
+ accessory.addArrangedSubview(secretField)
|
|
|
3092
|
+ alert.accessoryView = accessory
|
|
|
3093
|
+
|
|
|
3094
|
+ alert.addButton(withTitle: "Save")
|
|
|
3095
|
+ alert.addButton(withTitle: "Cancel")
|
|
|
3096
|
+
|
|
|
3097
|
+ // Keep this synchronous to avoid additional sheet state handling.
|
|
|
3098
|
+ let response = alert.runModal()
|
|
|
3099
|
+ if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
|
|
|
3100
|
+
|
|
|
3101
|
+ let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
3102
|
+ let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
3103
|
+ if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
|
|
|
3104
|
+ if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
|
|
|
3105
|
+
|
|
|
3106
|
+ googleOAuth.setClientIdForTesting(idValue)
|
|
|
3107
|
+ googleOAuth.setClientSecretForTesting(secretValue)
|
|
|
3108
|
+ }
|
|
|
3109
|
+
|
|
|
3110
|
+ func showSimpleError(_ title: String, error: Error) {
|
|
|
3111
|
+ DispatchQueue.main.async {
|
|
|
3112
|
+ let alert = NSAlert()
|
|
|
3113
|
+ alert.alertStyle = .warning
|
|
|
3114
|
+ alert.messageText = title
|
|
|
3115
|
+ alert.informativeText = error.localizedDescription
|
|
|
3116
|
+ alert.addButton(withTitle: "OK")
|
|
|
3117
|
+ alert.runModal()
|
|
|
3118
|
+ }
|
|
|
3119
|
+ }
|
|
|
3120
|
+}
|
|
|
3121
|
+
|
|
2407
|
3122
|
private struct Palette {
|
|
2408
|
3123
|
let pageBackground: NSColor
|
|
2409
|
3124
|
let sidebarBackground: NSColor
|
|
|
@@ -2514,11 +3229,8 @@ private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -
|
|
2514
|
3229
|
}
|
|
2515
|
3230
|
|
|
2516
|
3231
|
private enum InAppBrowserWebKitSupport {
|
|
2517
|
|
- static let sharedProcessPool = WKProcessPool()
|
|
2518
|
|
-
|
|
2519
|
3232
|
static func makeWebViewConfiguration() -> WKWebViewConfiguration {
|
|
2520
|
3233
|
let config = WKWebViewConfiguration()
|
|
2521
|
|
- config.processPool = sharedProcessPool
|
|
2522
|
3234
|
config.websiteDataStore = .default()
|
|
2523
|
3235
|
config.preferences.javaScriptCanOpenWindowsAutomatically = true
|
|
2524
|
3236
|
if #available(macOS 12.3, *) {
|