|
|
@@ -74,6 +74,9 @@ final class ViewController: NSViewController {
|
|
74
|
74
|
private var scheduleFilter: ScheduleFilter = .all
|
|
75
|
75
|
private weak var scheduleDateHeadingLabel: NSTextField?
|
|
76
|
76
|
private weak var scheduleCardsStack: NSStackView?
|
|
|
77
|
+ private weak var scheduleCardsScrollView: NSScrollView?
|
|
|
78
|
+ private weak var scheduleScrollLeftButton: NSView?
|
|
|
79
|
+ private weak var scheduleScrollRightButton: NSView?
|
|
77
|
80
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
78
|
81
|
|
|
79
|
82
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
|
@@ -1883,24 +1886,40 @@ private extension ViewController {
|
|
1883
|
1886
|
}
|
|
1884
|
1887
|
|
|
1885
|
1888
|
func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
|
|
|
1889
|
+ let cardWidth: CGFloat = 240
|
|
|
1890
|
+ let cardsPerViewport: CGFloat = 3
|
|
|
1891
|
+ let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
|
|
|
1892
|
+
|
|
|
1893
|
+ let wrapper = NSStackView()
|
|
|
1894
|
+ wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1895
|
+ wrapper.orientation = .horizontal
|
|
|
1896
|
+ wrapper.alignment = .centerY
|
|
|
1897
|
+ wrapper.spacing = 10
|
|
|
1898
|
+ let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
|
|
|
1899
|
+ scheduleScrollLeftButton = leftButton
|
|
|
1900
|
+ wrapper.addArrangedSubview(leftButton)
|
|
|
1901
|
+
|
|
1886
|
1902
|
let scroll = NSScrollView()
|
|
|
1903
|
+ scheduleCardsScrollView = scroll
|
|
1887
|
1904
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
1888
|
1905
|
scroll.drawsBackground = false
|
|
1889
|
|
- scroll.hasHorizontalScroller = true
|
|
|
1906
|
+ scroll.hasHorizontalScroller = false
|
|
1890
|
1907
|
scroll.hasVerticalScroller = false
|
|
1891
|
1908
|
scroll.horizontalScrollElasticity = .allowed
|
|
1892
|
1909
|
scroll.verticalScrollElasticity = .none
|
|
1893
|
|
- scroll.autohidesScrollers = true
|
|
|
1910
|
+ scroll.autohidesScrollers = false
|
|
1894
|
1911
|
scroll.borderType = .noBorder
|
|
|
1912
|
+ scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
1913
|
+ scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
|
|
1895
|
1914
|
|
|
1896
|
1915
|
let row = NSStackView()
|
|
1897
|
1916
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
1898
|
1917
|
row.orientation = .horizontal
|
|
1899
|
|
- row.spacing = 10
|
|
|
1918
|
+ row.spacing = 12
|
|
1900
|
1919
|
row.alignment = .top
|
|
1901
|
1920
|
row.distribution = .fill
|
|
1902
|
1921
|
row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
1903
|
|
- row.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
1922
|
+ row.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
1904
|
1923
|
scheduleCardsStack = row
|
|
1905
|
1924
|
|
|
1906
|
1925
|
scroll.documentView = row
|
|
|
@@ -1909,59 +1928,94 @@ private extension ViewController {
|
|
1909
|
1928
|
// Ensure the stack view determines content size for horizontal scrolling.
|
|
1910
|
1929
|
NSLayoutConstraint.activate([
|
|
1911
|
1930
|
row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
|
1912
|
|
- row.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
|
|
|
1931
|
+ row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
|
|
1913
|
1932
|
row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
|
|
1914
|
1933
|
row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
|
|
1915
|
|
- row.heightAnchor.constraint(equalToConstant: 136)
|
|
|
1934
|
+ row.heightAnchor.constraint(equalToConstant: 150)
|
|
1916
|
1935
|
])
|
|
1917
|
1936
|
|
|
1918
|
1937
|
renderScheduleCards(into: row, meetings: meetings)
|
|
1919
|
|
- return scroll
|
|
|
1938
|
+ wrapper.addArrangedSubview(scroll)
|
|
|
1939
|
+ let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
|
|
|
1940
|
+ scheduleScrollRightButton = rightButton
|
|
|
1941
|
+ wrapper.addArrangedSubview(rightButton)
|
|
|
1942
|
+ scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
1943
|
+ scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
1944
|
+ wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
1945
|
+ return wrapper
|
|
1920
|
1946
|
}
|
|
1921
|
1947
|
|
|
1922
|
1948
|
func scheduleCard(meeting: ScheduledMeeting) -> NSView {
|
|
1923
|
|
- let cardWidth: CGFloat = 264
|
|
|
1949
|
+ let cardWidth: CGFloat = 240
|
|
1924
|
1950
|
|
|
1925
|
|
- let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
|
1951
|
+ let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
|
|
1926
|
1952
|
styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
|
|
1927
|
1953
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
1928
|
1954
|
card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
|
|
1929
|
|
- card.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
1955
|
+ card.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
1930
|
1956
|
|
|
1931
|
|
- let icon = roundedContainer(cornerRadius: 5, color: palette.meetingBadge)
|
|
|
1957
|
+ let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
|
|
1932
|
1958
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
1933
|
|
- icon.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
|
1934
|
|
- icon.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
1935
|
|
- let iconText = textLabel("••", font: typography.cardIcon, color: .white)
|
|
1936
|
|
- iconText.translatesAutoresizingMaskIntoConstraints = false
|
|
1937
|
|
- icon.addSubview(iconText)
|
|
|
1959
|
+ icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
1960
|
+ icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
1961
|
+ let iconView = NSImageView()
|
|
|
1962
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1963
|
+ iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
|
|
|
1964
|
+ iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
|
|
1965
|
+ iconView.contentTintColor = .white
|
|
|
1966
|
+ icon.addSubview(iconView)
|
|
1938
|
1967
|
NSLayoutConstraint.activate([
|
|
1939
|
|
- iconText.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
|
|
1940
|
|
- iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
|
|
|
1968
|
+ iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
|
|
|
1969
|
+ iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
|
|
1941
|
1970
|
])
|
|
1942
|
1971
|
|
|
1943
|
1972
|
let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
|
|
1944
|
1973
|
let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
|
|
1945
|
1974
|
let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
|
|
|
1975
|
+ let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
|
|
|
1976
|
+ let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
|
|
|
1977
|
+ dayChip.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1978
|
+ dayChip.layer?.borderWidth = 1
|
|
|
1979
|
+ dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
|
|
|
1980
|
+ let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
|
|
|
1981
|
+ dayText.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1982
|
+ dayChip.addSubview(dayText)
|
|
|
1983
|
+ NSLayoutConstraint.activate([
|
|
|
1984
|
+ dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
|
|
|
1985
|
+ dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
|
|
|
1986
|
+ dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
|
|
|
1987
|
+ dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
|
|
|
1988
|
+ ])
|
|
1946
|
1989
|
|
|
1947
|
1990
|
card.addSubview(icon)
|
|
|
1991
|
+ card.addSubview(dayChip)
|
|
1948
|
1992
|
card.addSubview(title)
|
|
1949
|
1993
|
card.addSubview(subtitle)
|
|
1950
|
1994
|
card.addSubview(time)
|
|
|
1995
|
+ card.addSubview(duration)
|
|
1951
|
1996
|
|
|
1952
|
1997
|
NSLayoutConstraint.activate([
|
|
1953
|
1998
|
icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
1954
|
1999
|
icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
|
|
1955
|
2000
|
|
|
|
2001
|
+ dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
|
|
|
2002
|
+ dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
|
|
|
2003
|
+
|
|
1956
|
2004
|
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
|
|
1957
|
2005
|
title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
|
|
1958
|
|
- title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
|
2006
|
+ title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
|
|
1959
|
2007
|
|
|
1960
|
2008
|
subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
1961
|
|
- subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 7),
|
|
|
2009
|
+ subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
|
|
|
2010
|
+ subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
1962
|
2011
|
|
|
1963
|
2012
|
time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
1964
|
|
- time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
|
|
|
2013
|
+ time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
|
|
|
2014
|
+ time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
|
2015
|
+
|
|
|
2016
|
+ duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
|
2017
|
+ duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
|
|
|
2018
|
+ duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
|
|
1965
|
2019
|
])
|
|
1966
|
2020
|
|
|
1967
|
2021
|
let hit = HoverTrackingView()
|
|
|
@@ -1987,6 +2041,27 @@ private extension ViewController {
|
|
1987
|
2041
|
|
|
1988
|
2042
|
return hit
|
|
1989
|
2043
|
}
|
|
|
2044
|
+
|
|
|
2045
|
+ private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
|
|
|
2046
|
+ let button = NSButton(title: "", target: self, action: action)
|
|
|
2047
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2048
|
+ button.isBordered = false
|
|
|
2049
|
+ button.bezelStyle = .regularSquare
|
|
|
2050
|
+ button.wantsLayer = true
|
|
|
2051
|
+ button.layer?.cornerRadius = 16
|
|
|
2052
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
2053
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
2054
|
+ button.layer?.borderWidth = 1
|
|
|
2055
|
+ button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
|
|
|
2056
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
|
|
|
2057
|
+ button.imagePosition = .imageOnly
|
|
|
2058
|
+ button.imageScaling = .scaleProportionallyDown
|
|
|
2059
|
+ button.contentTintColor = palette.textSecondary
|
|
|
2060
|
+ button.focusRingType = .none
|
|
|
2061
|
+ button.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
|
|
2062
|
+ button.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
|
|
2063
|
+ return button
|
|
|
2064
|
+ }
|
|
1990
|
2065
|
}
|
|
1991
|
2066
|
|
|
1992
|
2067
|
extension ViewController: NSTextFieldDelegate {
|
|
|
@@ -2538,6 +2613,14 @@ private extension ViewController {
|
|
2538
|
2613
|
scheduleReloadClicked()
|
|
2539
|
2614
|
}
|
|
2540
|
2615
|
|
|
|
2616
|
+ @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
|
|
|
2617
|
+ scrollScheduleCards(direction: -1)
|
|
|
2618
|
+ }
|
|
|
2619
|
+
|
|
|
2620
|
+ @objc func scheduleScrollRightPressed(_ sender: NSButton) {
|
|
|
2621
|
+ scrollScheduleCards(direction: 1)
|
|
|
2622
|
+ }
|
|
|
2623
|
+
|
|
2541
|
2624
|
@objc func scheduleConnectButtonPressed(_ sender: NSButton) {
|
|
2542
|
2625
|
scheduleConnectClicked()
|
|
2543
|
2626
|
}
|
|
|
@@ -2570,6 +2653,25 @@ private extension ViewController {
|
|
2570
|
2653
|
return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
|
|
2571
|
2654
|
}
|
|
2572
|
2655
|
|
|
|
2656
|
+ private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
|
|
|
2657
|
+ let f = DateFormatter()
|
|
|
2658
|
+ f.locale = Locale.current
|
|
|
2659
|
+ f.timeZone = TimeZone.current
|
|
|
2660
|
+ f.dateFormat = "EEE, d MMM"
|
|
|
2661
|
+ return f.string(from: meeting.startDate)
|
|
|
2662
|
+ }
|
|
|
2663
|
+
|
|
|
2664
|
+ private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
|
|
|
2665
|
+ if meeting.isAllDay { return "Duration: all day" }
|
|
|
2666
|
+ let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
|
|
|
2667
|
+ let totalMinutes = Int(duration / 60)
|
|
|
2668
|
+ let hours = totalMinutes / 60
|
|
|
2669
|
+ let minutes = totalMinutes % 60
|
|
|
2670
|
+ if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
|
|
|
2671
|
+ if hours > 0 { return "Duration: \(hours)h" }
|
|
|
2672
|
+ return "Duration: \(minutes)m"
|
|
|
2673
|
+ }
|
|
|
2674
|
+
|
|
2573
|
2675
|
private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
|
|
2574
|
2676
|
guard let first = meetings.first else {
|
|
2575
|
2677
|
return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
|
|
|
@@ -2588,6 +2690,14 @@ private extension ViewController {
|
|
2588
|
2690
|
}
|
|
2589
|
2691
|
|
|
2590
|
2692
|
private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
|
|
|
2693
|
+ let shouldShowScrollControls = meetings.count > 3
|
|
|
2694
|
+ scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
|
|
|
2695
|
+ scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
|
|
|
2696
|
+ scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
|
|
|
2697
|
+ if let scroll = scheduleCardsScrollView {
|
|
|
2698
|
+ scroll.reflectScrolledClipView(scroll.contentView)
|
|
|
2699
|
+ }
|
|
|
2700
|
+
|
|
2591
|
2701
|
stack.arrangedSubviews.forEach { v in
|
|
2592
|
2702
|
stack.removeArrangedSubview(v)
|
|
2593
|
2703
|
v.removeFromSuperview()
|
|
|
@@ -2596,8 +2706,8 @@ private extension ViewController {
|
|
2596
|
2706
|
if meetings.isEmpty {
|
|
2597
|
2707
|
let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
2598
|
2708
|
empty.translatesAutoresizingMaskIntoConstraints = false
|
|
2599
|
|
- empty.widthAnchor.constraint(equalToConstant: 264).isActive = true
|
|
2600
|
|
- empty.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
2709
|
+ empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
|
|
|
2710
|
+ empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
2601
|
2711
|
styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
2602
|
2712
|
|
|
2603
|
2713
|
let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
|
|
|
@@ -2631,6 +2741,17 @@ private extension ViewController {
|
|
2631
|
2741
|
}
|
|
2632
|
2742
|
}
|
|
2633
|
2743
|
|
|
|
2744
|
+ private func scrollScheduleCards(direction: Int) {
|
|
|
2745
|
+ guard let scroll = scheduleCardsScrollView else { return }
|
|
|
2746
|
+ let contentBounds = scroll.contentView.bounds
|
|
|
2747
|
+ let step = max(220, contentBounds.width * 0.7)
|
|
|
2748
|
+ let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
|
|
|
2749
|
+ let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
|
|
|
2750
|
+ let nextX = min(max(0, proposedX), maxX)
|
|
|
2751
|
+ scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
|
|
|
2752
|
+ scroll.reflectScrolledClipView(scroll.contentView)
|
|
|
2753
|
+ }
|
|
|
2754
|
+
|
|
2634
|
2755
|
private func loadSchedule() async {
|
|
2635
|
2756
|
do {
|
|
2636
|
2757
|
if googleOAuth.loadTokens() == nil {
|