|
|
@@ -74,6 +74,9 @@ final class ViewController: NSViewController {
|
|
|
private var scheduleFilter: ScheduleFilter = .all
|
|
|
private weak var scheduleDateHeadingLabel: NSTextField?
|
|
|
private weak var scheduleCardsStack: NSStackView?
|
|
|
+ private weak var scheduleCardsScrollView: NSScrollView?
|
|
|
+ private weak var scheduleScrollLeftButton: NSView?
|
|
|
+ private weak var scheduleScrollRightButton: NSView?
|
|
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
|
|
|
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
|
@@ -1883,24 +1886,40 @@ private extension ViewController {
|
|
|
}
|
|
|
|
|
|
func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
|
|
|
+ let cardWidth: CGFloat = 240
|
|
|
+ let cardsPerViewport: CGFloat = 3
|
|
|
+ let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
|
|
|
+
|
|
|
+ let wrapper = NSStackView()
|
|
|
+ wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ wrapper.orientation = .horizontal
|
|
|
+ wrapper.alignment = .centerY
|
|
|
+ wrapper.spacing = 10
|
|
|
+ let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
|
|
|
+ scheduleScrollLeftButton = leftButton
|
|
|
+ wrapper.addArrangedSubview(leftButton)
|
|
|
+
|
|
|
let scroll = NSScrollView()
|
|
|
+ scheduleCardsScrollView = scroll
|
|
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
scroll.drawsBackground = false
|
|
|
- scroll.hasHorizontalScroller = true
|
|
|
+ scroll.hasHorizontalScroller = false
|
|
|
scroll.hasVerticalScroller = false
|
|
|
scroll.horizontalScrollElasticity = .allowed
|
|
|
scroll.verticalScrollElasticity = .none
|
|
|
- scroll.autohidesScrollers = true
|
|
|
+ scroll.autohidesScrollers = false
|
|
|
scroll.borderType = .noBorder
|
|
|
+ scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
+ scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
|
|
|
|
|
|
let row = NSStackView()
|
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
row.orientation = .horizontal
|
|
|
- row.spacing = 10
|
|
|
+ row.spacing = 12
|
|
|
row.alignment = .top
|
|
|
row.distribution = .fill
|
|
|
row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
|
- row.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
+ row.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
scheduleCardsStack = row
|
|
|
|
|
|
scroll.documentView = row
|
|
|
@@ -1909,59 +1928,94 @@ private extension ViewController {
|
|
|
// Ensure the stack view determines content size for horizontal scrolling.
|
|
|
NSLayoutConstraint.activate([
|
|
|
row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
|
|
- row.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
|
|
|
+ row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
|
|
|
row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
|
|
|
row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
|
|
|
- row.heightAnchor.constraint(equalToConstant: 136)
|
|
|
+ row.heightAnchor.constraint(equalToConstant: 150)
|
|
|
])
|
|
|
|
|
|
renderScheduleCards(into: row, meetings: meetings)
|
|
|
- return scroll
|
|
|
+ wrapper.addArrangedSubview(scroll)
|
|
|
+ let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
|
|
|
+ scheduleScrollRightButton = rightButton
|
|
|
+ wrapper.addArrangedSubview(rightButton)
|
|
|
+ scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
+ scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
+ wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
+ return wrapper
|
|
|
}
|
|
|
|
|
|
func scheduleCard(meeting: ScheduledMeeting) -> NSView {
|
|
|
- let cardWidth: CGFloat = 264
|
|
|
+ let cardWidth: CGFloat = 240
|
|
|
|
|
|
- let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
|
+ let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
|
|
|
styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
|
|
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
|
|
|
- card.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
+ card.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
|
|
|
- let icon = roundedContainer(cornerRadius: 5, color: palette.meetingBadge)
|
|
|
+ let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
|
|
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- icon.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
|
|
- icon.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
|
- let iconText = textLabel("••", font: typography.cardIcon, color: .white)
|
|
|
- iconText.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- icon.addSubview(iconText)
|
|
|
+ icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
+ icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
+ let iconView = NSImageView()
|
|
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
|
|
|
+ iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
|
|
+ iconView.contentTintColor = .white
|
|
|
+ icon.addSubview(iconView)
|
|
|
NSLayoutConstraint.activate([
|
|
|
- iconText.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
|
|
|
- iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
|
|
|
+ iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
|
|
|
+ iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
|
|
|
])
|
|
|
|
|
|
let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
|
|
|
let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
|
|
|
let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
|
|
|
+ let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
|
|
|
+ let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
|
|
|
+ dayChip.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ dayChip.layer?.borderWidth = 1
|
|
|
+ dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
|
|
|
+ let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
|
|
|
+ dayText.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ dayChip.addSubview(dayText)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
|
|
|
+ dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
|
|
|
+ dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
|
|
|
+ dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
|
|
|
+ ])
|
|
|
|
|
|
card.addSubview(icon)
|
|
|
+ card.addSubview(dayChip)
|
|
|
card.addSubview(title)
|
|
|
card.addSubview(subtitle)
|
|
|
card.addSubview(time)
|
|
|
+ card.addSubview(duration)
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
|
icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
|
|
|
|
|
|
+ dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
|
|
|
+ dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
|
|
|
+
|
|
|
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
|
|
|
title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
|
|
|
- title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
|
+ title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
|
|
|
|
|
|
subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
|
- subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 7),
|
|
|
+ subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
|
|
|
+ subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
|
|
|
|
time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
|
- time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
|
|
|
+ time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
|
|
|
+ time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
|
|
|
+
|
|
|
+ duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
|
+ duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
|
|
|
+ duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
|
|
|
])
|
|
|
|
|
|
let hit = HoverTrackingView()
|
|
|
@@ -1987,6 +2041,27 @@ private extension ViewController {
|
|
|
|
|
|
return hit
|
|
|
}
|
|
|
+
|
|
|
+ private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
|
|
|
+ let button = NSButton(title: "", target: self, action: action)
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ button.isBordered = false
|
|
|
+ button.bezelStyle = .regularSquare
|
|
|
+ button.wantsLayer = true
|
|
|
+ button.layer?.cornerRadius = 16
|
|
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
+ button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
|
|
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
|
|
|
+ button.imagePosition = .imageOnly
|
|
|
+ button.imageScaling = .scaleProportionallyDown
|
|
|
+ button.contentTintColor = palette.textSecondary
|
|
|
+ button.focusRingType = .none
|
|
|
+ button.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
|
|
+ button.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
|
|
+ return button
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
extension ViewController: NSTextFieldDelegate {
|
|
|
@@ -2538,6 +2613,14 @@ private extension ViewController {
|
|
|
scheduleReloadClicked()
|
|
|
}
|
|
|
|
|
|
+ @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
|
|
|
+ scrollScheduleCards(direction: -1)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc func scheduleScrollRightPressed(_ sender: NSButton) {
|
|
|
+ scrollScheduleCards(direction: 1)
|
|
|
+ }
|
|
|
+
|
|
|
@objc func scheduleConnectButtonPressed(_ sender: NSButton) {
|
|
|
scheduleConnectClicked()
|
|
|
}
|
|
|
@@ -2570,6 +2653,25 @@ private extension ViewController {
|
|
|
return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
|
|
|
}
|
|
|
|
|
|
+ private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
|
|
|
+ let f = DateFormatter()
|
|
|
+ f.locale = Locale.current
|
|
|
+ f.timeZone = TimeZone.current
|
|
|
+ f.dateFormat = "EEE, d MMM"
|
|
|
+ return f.string(from: meeting.startDate)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
|
|
|
+ if meeting.isAllDay { return "Duration: all day" }
|
|
|
+ let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
|
|
|
+ let totalMinutes = Int(duration / 60)
|
|
|
+ let hours = totalMinutes / 60
|
|
|
+ let minutes = totalMinutes % 60
|
|
|
+ if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
|
|
|
+ if hours > 0 { return "Duration: \(hours)h" }
|
|
|
+ return "Duration: \(minutes)m"
|
|
|
+ }
|
|
|
+
|
|
|
private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
|
|
|
guard let first = meetings.first else {
|
|
|
return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
|
|
|
@@ -2588,6 +2690,14 @@ private extension ViewController {
|
|
|
}
|
|
|
|
|
|
private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
|
|
|
+ let shouldShowScrollControls = meetings.count > 3
|
|
|
+ scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
|
|
|
+ scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
|
|
|
+ scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
|
|
|
+ if let scroll = scheduleCardsScrollView {
|
|
|
+ scroll.reflectScrolledClipView(scroll.contentView)
|
|
|
+ }
|
|
|
+
|
|
|
stack.arrangedSubviews.forEach { v in
|
|
|
stack.removeArrangedSubview(v)
|
|
|
v.removeFromSuperview()
|
|
|
@@ -2596,8 +2706,8 @@ private extension ViewController {
|
|
|
if meetings.isEmpty {
|
|
|
let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
|
empty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- empty.widthAnchor.constraint(equalToConstant: 264).isActive = true
|
|
|
- empty.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
+ empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
|
|
|
+ empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
|
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 {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private func scrollScheduleCards(direction: Int) {
|
|
|
+ guard let scroll = scheduleCardsScrollView else { return }
|
|
|
+ let contentBounds = scroll.contentView.bounds
|
|
|
+ let step = max(220, contentBounds.width * 0.7)
|
|
|
+ let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
|
|
|
+ let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
|
|
|
+ let nextX = min(max(0, proposedX), maxX)
|
|
|
+ scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
|
|
|
+ scroll.reflectScrolledClipView(scroll.contentView)
|
|
|
+ }
|
|
|
+
|
|
|
private func loadSchedule() async {
|
|
|
do {
|
|
|
if googleOAuth.loadTokens() == nil {
|