|
|
@@ -7,6 +7,7 @@
|
|
|
|
|
|
import Cocoa
|
|
|
import WebKit
|
|
|
+import AuthenticationServices
|
|
|
|
|
|
private enum SidebarPage: Int {
|
|
|
case joinMeetings = 0
|
|
|
@@ -37,6 +38,12 @@ private enum PremiumPlan: Int {
|
|
|
}
|
|
|
|
|
|
final class ViewController: NSViewController {
|
|
|
+ private struct GoogleProfileDisplay {
|
|
|
+ let name: String
|
|
|
+ let email: String
|
|
|
+ let pictureURL: URL?
|
|
|
+ }
|
|
|
+
|
|
|
private var palette = Palette(isDarkMode: true)
|
|
|
private let typography = Typography()
|
|
|
private let launchContentSize = NSSize(width: 920, height: 690)
|
|
|
@@ -61,6 +68,27 @@ final class ViewController: NSViewController {
|
|
|
private weak var meetLinkField: NSTextField?
|
|
|
private weak var browseAddressField: NSTextField?
|
|
|
private var inAppBrowserWindowController: InAppBrowserWindowController?
|
|
|
+ private let googleOAuth = GoogleOAuthService.shared
|
|
|
+ private let calendarClient = GoogleCalendarClient()
|
|
|
+
|
|
|
+ private enum ScheduleFilter: Int {
|
|
|
+ case all = 0
|
|
|
+ case today = 1
|
|
|
+ case week = 2
|
|
|
+ }
|
|
|
+
|
|
|
+ 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?
|
|
|
+ private weak var scheduleGoogleAuthButton: NSButton?
|
|
|
+ private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
|
|
|
+ private var scheduleGoogleAuthHovering = false
|
|
|
+ private var scheduleCurrentProfile: GoogleProfileDisplay?
|
|
|
+ private var scheduleProfileImageTask: Task<Void, Never>?
|
|
|
|
|
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
|
@@ -903,14 +931,24 @@ private extension ViewController {
|
|
|
contentStack.spacing = 14
|
|
|
contentStack.alignment = .leading
|
|
|
|
|
|
+ contentStack.addArrangedSubview(scheduleTopAuthRow())
|
|
|
+ if let authRow = contentStack.arrangedSubviews.last {
|
|
|
+ contentStack.setCustomSpacing(20, after: authRow)
|
|
|
+ }
|
|
|
+
|
|
|
let joinActions = meetJoinActionsRow()
|
|
|
contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
|
|
|
contentStack.addArrangedSubview(meetJoinSectionRow())
|
|
|
contentStack.addArrangedSubview(joinActions)
|
|
|
contentStack.setCustomSpacing(26, after: joinActions)
|
|
|
contentStack.addArrangedSubview(scheduleHeader())
|
|
|
- contentStack.addArrangedSubview(textLabel("Tuesday, 14 Apr", font: typography.dateHeading, color: palette.textSecondary))
|
|
|
- contentStack.addArrangedSubview(scheduleCardsRow())
|
|
|
+
|
|
|
+ let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
|
|
|
+ scheduleDateHeadingLabel = dateHeading
|
|
|
+ contentStack.addArrangedSubview(dateHeading)
|
|
|
+
|
|
|
+ let cardsRow = scheduleCardsRow(meetings: [])
|
|
|
+ contentStack.addArrangedSubview(cardsRow)
|
|
|
|
|
|
panel.addSubview(contentStack)
|
|
|
|
|
|
@@ -920,6 +958,10 @@ private extension ViewController {
|
|
|
contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26)
|
|
|
])
|
|
|
|
|
|
+ Task { [weak self] in
|
|
|
+ await self?.loadSchedule()
|
|
|
+ }
|
|
|
+
|
|
|
return panel
|
|
|
}
|
|
|
|
|
|
@@ -1777,94 +1819,317 @@ private extension ViewController {
|
|
|
row.addArrangedSubview(spacer)
|
|
|
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
|
|
|
- row.addArrangedSubview(iconRoundButton("?", size: 34))
|
|
|
- row.addArrangedSubview(iconRoundButton("⟳", size: 34))
|
|
|
-
|
|
|
- let filter = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
|
|
|
- filter.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- filter.widthAnchor.constraint(equalToConstant: 156).isActive = true
|
|
|
- filter.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
- styleSurface(filter, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
- let filterText = textLabel("All", font: typography.filterText, color: palette.textSecondary)
|
|
|
- let arrow = textLabel("▾", font: typography.filterArrow, color: palette.textMuted)
|
|
|
- filterText.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- arrow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- filter.addSubview(filterText)
|
|
|
- filter.addSubview(arrow)
|
|
|
+ row.addArrangedSubview(makeScheduleRefreshButton())
|
|
|
|
|
|
- NSLayoutConstraint.activate([
|
|
|
- filterText.leadingAnchor.constraint(equalTo: filter.leadingAnchor, constant: 12),
|
|
|
- filterText.centerYAnchor.constraint(equalTo: filter.centerYAnchor),
|
|
|
- arrow.trailingAnchor.constraint(equalTo: filter.trailingAnchor, constant: -10),
|
|
|
- arrow.centerYAnchor.constraint(equalTo: filter.centerYAnchor)
|
|
|
- ])
|
|
|
-
|
|
|
- row.addArrangedSubview(filter)
|
|
|
+ row.addArrangedSubview(makeScheduleFilterDropdown())
|
|
|
row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
return row
|
|
|
}
|
|
|
|
|
|
- func scheduleCardsRow() -> NSView {
|
|
|
+ private func scheduleTopAuthRow() -> NSView {
|
|
|
let row = NSStackView()
|
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
row.orientation = .horizontal
|
|
|
+ row.alignment = .centerY
|
|
|
row.spacing = 10
|
|
|
+
|
|
|
+ let spacer = NSView()
|
|
|
+ spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ row.addArrangedSubview(spacer)
|
|
|
+ spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
+
|
|
|
+ let authButton = makeGoogleAuthButton()
|
|
|
+ scheduleGoogleAuthButton = authButton
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+ row.addArrangedSubview(authButton)
|
|
|
+
|
|
|
+ row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
+ return row
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeScheduleFilterDropdown() -> NSPopUpButton {
|
|
|
+ let button = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ button.autoenablesItems = false
|
|
|
+ button.wantsLayer = true
|
|
|
+ button.layer?.cornerRadius = 8
|
|
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
+ button.font = typography.filterText
|
|
|
+ button.contentTintColor = palette.textSecondary
|
|
|
+ button.target = self
|
|
|
+ button.action = #selector(scheduleFilterDropdownChanged(_:))
|
|
|
+ button.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
+ button.widthAnchor.constraint(equalToConstant: 156).isActive = true
|
|
|
+
|
|
|
+ button.removeAllItems()
|
|
|
+ button.addItems(withTitles: ["All", "Today", "This week"])
|
|
|
+ button.selectItem(at: scheduleFilter.rawValue)
|
|
|
+
|
|
|
+ if let menu = button.menu {
|
|
|
+ for (index, item) in menu.items.enumerated() {
|
|
|
+ item.tag = index
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ scheduleFilterDropdown = button
|
|
|
+ return button
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeSchedulePillButton(title: String) -> NSButton {
|
|
|
+ let button = NSButton(title: title, target: nil, action: nil)
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ button.isBordered = false
|
|
|
+ button.bezelStyle = .regularSquare
|
|
|
+ button.wantsLayer = true
|
|
|
+ button.layer?.cornerRadius = 8
|
|
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
+ button.font = typography.filterText
|
|
|
+ button.contentTintColor = palette.textSecondary
|
|
|
+ button.setButtonType(.momentaryChange)
|
|
|
+ button.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
+ button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
|
|
|
+ return button
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeGoogleAuthButton() -> NSButton {
|
|
|
+ let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ button.isBordered = false
|
|
|
+ button.bezelStyle = .regularSquare
|
|
|
+ button.wantsLayer = true
|
|
|
+ button.layer?.cornerRadius = 21
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
+ button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
|
|
|
+ button.imagePosition = .imageLeading
|
|
|
+ button.alignment = .center
|
|
|
+ button.imageHugsTitle = true
|
|
|
+ button.lineBreakMode = .byTruncatingTail
|
|
|
+ button.contentTintColor = palette.textPrimary
|
|
|
+ button.imageScaling = .scaleNone
|
|
|
+ button.heightAnchor.constraint(equalToConstant: 42).isActive = true
|
|
|
+ let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
|
|
|
+ widthConstraint.isActive = true
|
|
|
+ scheduleGoogleAuthButtonWidthConstraint = widthConstraint
|
|
|
+ button.onHoverChanged = { [weak self] hovering in
|
|
|
+ self?.scheduleGoogleAuthHovering = hovering
|
|
|
+ self?.applyGoogleAuthButtonSurface()
|
|
|
+ }
|
|
|
+ button.onHoverChanged?(false)
|
|
|
+ return button
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeScheduleRefreshButton() -> NSButton {
|
|
|
+ let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ button.isBordered = false
|
|
|
+ button.bezelStyle = .regularSquare
|
|
|
+ button.wantsLayer = true
|
|
|
+ button.layer?.cornerRadius = 21
|
|
|
+ button.layer?.backgroundColor = palette.inputBackground.cgColor
|
|
|
+ button.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
+ button.setButtonType(.momentaryChange)
|
|
|
+ button.contentTintColor = palette.textSecondary
|
|
|
+ button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
|
|
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
|
|
+ button.imagePosition = .imageOnly
|
|
|
+ button.imageScaling = .scaleProportionallyDown
|
|
|
+ button.focusRingType = .none
|
|
|
+ button.heightAnchor.constraint(equalToConstant: 42).isActive = true
|
|
|
+ button.widthAnchor.constraint(equalToConstant: 42).isActive = true
|
|
|
+ return button
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 = false
|
|
|
+ scroll.hasVerticalScroller = false
|
|
|
+ scroll.horizontalScrollElasticity = .allowed
|
|
|
+ scroll.verticalScrollElasticity = .none
|
|
|
+ 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 = 12
|
|
|
row.alignment = .top
|
|
|
- row.distribution = .fill
|
|
|
+ row.distribution = .gravityAreas
|
|
|
row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
|
- row.heightAnchor.constraint(equalToConstant: 136).isActive = true
|
|
|
+ row.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
+ scheduleCardsStack = row
|
|
|
|
|
|
- row.addArrangedSubview(scheduleCard())
|
|
|
- row.addArrangedSubview(scheduleCard())
|
|
|
- return row
|
|
|
+ scroll.documentView = row
|
|
|
+ scroll.contentView.postsBoundsChangedNotifications = true
|
|
|
+
|
|
|
+ // Ensure the stack view determines content size for horizontal scrolling.
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
|
|
+ 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: 150)
|
|
|
+ ])
|
|
|
+
|
|
|
+ renderScheduleCards(into: row, meetings: meetings)
|
|
|
+ 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() -> NSView {
|
|
|
- let cardWidth: CGFloat = 264
|
|
|
+ func scheduleCard(meeting: ScheduledMeeting) -> NSView {
|
|
|
+ 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
|
|
|
+ card.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
+ card.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
|
|
- 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("General Meeting", font: typography.cardTitle, color: palette.textPrimary)
|
|
|
- let subtitle = textLabel("Baisakhi", font: typography.cardSubtitle, color: palette.textPrimary)
|
|
|
- let time = textLabel("12:00 AM - 11:59 PM", font: typography.cardTime, color: palette.textSecondary)
|
|
|
+ let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
|
|
|
+ title.lineBreakMode = .byTruncatingTail
|
|
|
+ title.maximumNumberOfLines = 1
|
|
|
+ title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
+ 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),
|
|
|
+ title.widthAnchor.constraint(lessThanOrEqualToConstant: 130),
|
|
|
|
|
|
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)
|
|
|
])
|
|
|
|
|
|
- return card
|
|
|
+ let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
|
|
|
+ hit.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ hit.isBordered = false
|
|
|
+ hit.bezelStyle = .regularSquare
|
|
|
+ hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
|
|
|
+ hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
|
|
|
+ hit.heightAnchor.constraint(equalToConstant: 150).isActive = true
|
|
|
+ hit.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
+ hit.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
+ hit.addSubview(card)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
|
|
|
+ card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
|
|
|
+ card.topAnchor.constraint(equalTo: hit.topAnchor),
|
|
|
+ card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
|
|
|
+ ])
|
|
|
+ hit.onHoverChanged = { [weak self] hovering in
|
|
|
+ guard let self else { return }
|
|
|
+ let base = self.palette.sectionCard
|
|
|
+ let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
|
|
|
+ let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
|
|
|
+ card.layer?.backgroundColor = (hovering ? hover : base).cgColor
|
|
|
+ }
|
|
|
+ hit.onHoverChanged?(false)
|
|
|
+
|
|
|
+ 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
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2375,7 +2640,7 @@ private extension ViewController {
|
|
|
return button
|
|
|
}
|
|
|
|
|
|
- func iconRoundButton(_ symbol: String, size: CGFloat) -> NSView {
|
|
|
+ func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
|
|
|
let button = HoverTrackingView()
|
|
|
button.wantsLayer = true
|
|
|
button.layer?.cornerRadius = size / 2
|
|
|
@@ -2385,11 +2650,16 @@ private extension ViewController {
|
|
|
button.heightAnchor.constraint(equalToConstant: size).isActive = true
|
|
|
styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
|
- let label = textLabel(symbol, font: typography.iconButton, color: palette.textSecondary)
|
|
|
- button.addSubview(label)
|
|
|
+ let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
|
|
|
+ let iconView = NSImageView()
|
|
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
|
|
|
+ iconView.symbolConfiguration = symbolConfig
|
|
|
+ iconView.contentTintColor = palette.textSecondary
|
|
|
+ button.addSubview(iconView)
|
|
|
NSLayoutConstraint.activate([
|
|
|
- label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
|
|
|
- label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
|
|
|
+ iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
|
|
|
+ iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
|
|
|
])
|
|
|
|
|
|
let baseColor = palette.inputBackground
|
|
|
@@ -2399,11 +2669,456 @@ private extension ViewController {
|
|
|
button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
|
|
|
}
|
|
|
button.onHoverChanged?(false)
|
|
|
+ button.onClick = onClick
|
|
|
|
|
|
return button
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// MARK: - Schedule actions (OAuth entry)
|
|
|
+
|
|
|
+private extension ViewController {
|
|
|
+ @objc func scheduleReloadButtonPressed(_ sender: NSButton) {
|
|
|
+ scheduleReloadClicked()
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
|
|
|
+ scrollScheduleCards(direction: -1)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc func scheduleScrollRightPressed(_ sender: NSButton) {
|
|
|
+ scrollScheduleCards(direction: 1)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc func scheduleCardButtonPressed(_ sender: NSButton) {
|
|
|
+ guard let raw = sender.identifier?.rawValue,
|
|
|
+ let url = URL(string: raw) else { return }
|
|
|
+ openMeetingURL(url)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
|
|
|
+ scheduleConnectClicked()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func scheduleInitialHeadingText() -> String {
|
|
|
+ googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
|
|
|
+ guard let selectedItem = sender.selectedItem,
|
|
|
+ let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
|
|
|
+ applyScheduleFilter(filter)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyScheduleFilter(_ filter: ScheduleFilter) {
|
|
|
+ scheduleFilter = filter
|
|
|
+ scheduleFilterDropdown?.selectItem(at: filter.rawValue)
|
|
|
+ Task { [weak self] in
|
|
|
+ await self?.loadSchedule()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
|
|
|
+ if meeting.isAllDay { return "All day" }
|
|
|
+ let f = DateFormatter()
|
|
|
+ f.locale = Locale.current
|
|
|
+ f.timeZone = TimeZone.current
|
|
|
+ f.dateStyle = .none
|
|
|
+ f.timeStyle = .short
|
|
|
+ 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"
|
|
|
+ }
|
|
|
+
|
|
|
+ let day = Calendar.current.startOfDay(for: first.startDate)
|
|
|
+ let f = DateFormatter()
|
|
|
+ f.locale = Locale.current
|
|
|
+ f.timeZone = TimeZone.current
|
|
|
+ f.dateFormat = "EEEE, d MMM"
|
|
|
+ return f.string(from: day)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func openMeetingURL(_ url: URL) {
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
+ }
|
|
|
+
|
|
|
+ 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()
|
|
|
+ }
|
|
|
+
|
|
|
+ if meetings.isEmpty {
|
|
|
+ let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
|
|
|
+ empty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ 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)
|
|
|
+ label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ empty.addSubview(label)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
|
|
|
+ label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
|
|
|
+ ])
|
|
|
+ stack.addArrangedSubview(empty)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ for meeting in meetings {
|
|
|
+ stack.addArrangedSubview(scheduleCard(meeting: meeting))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
|
|
|
+ switch scheduleFilter {
|
|
|
+ case .all:
|
|
|
+ return meetings
|
|
|
+ case .today:
|
|
|
+ let start = Calendar.current.startOfDay(for: Date())
|
|
|
+ let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
|
|
|
+ return meetings.filter { $0.startDate >= start && $0.startDate < end }
|
|
|
+ case .week:
|
|
|
+ let now = Date()
|
|
|
+ let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
|
|
|
+ return meetings.filter { $0.startDate >= now && $0.startDate <= end }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 {
|
|
|
+ await MainActor.run {
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+ applyGoogleProfile(nil)
|
|
|
+ scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
+ if let stack = scheduleCardsStack {
|
|
|
+ renderScheduleCards(into: stack, meetings: [])
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
+ let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
+ let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
|
|
|
+ let filtered = filteredMeetings(meetings)
|
|
|
+
|
|
|
+ await MainActor.run {
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+ applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
|
+ scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
|
|
|
+ if let stack = scheduleCardsStack {
|
|
|
+ renderScheduleCards(into: stack, meetings: filtered)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ await MainActor.run {
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+ if googleOAuth.loadTokens() == nil {
|
|
|
+ applyGoogleProfile(nil)
|
|
|
+ }
|
|
|
+ scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
|
|
|
+ if let stack = scheduleCardsStack {
|
|
|
+ renderScheduleCards(into: stack, meetings: [])
|
|
|
+ }
|
|
|
+ showSimpleError("Couldn’t load schedule.", error: error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func showScheduleHelp() {
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = "Google schedule"
|
|
|
+ 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."
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
+ alert.runModal()
|
|
|
+ }
|
|
|
+
|
|
|
+ func scheduleReloadClicked() {
|
|
|
+ Task { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ do {
|
|
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
+ _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
+ await MainActor.run {
|
|
|
+ self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
|
|
|
+ self.pageCache[.joinMeetings] = nil
|
|
|
+ self.showSidebarPage(.joinMeetings)
|
|
|
+ }
|
|
|
+ await self.loadSchedule()
|
|
|
+ } catch {
|
|
|
+ await MainActor.run {
|
|
|
+ self.showSimpleError("Couldn’t refresh schedule.", error: error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func scheduleConnectClicked() {
|
|
|
+ Task { [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ do {
|
|
|
+ if self.googleOAuth.loadTokens() != nil {
|
|
|
+ await MainActor.run { self.showGoogleAccountMenu() }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
|
|
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
+ let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
|
|
|
+ await MainActor.run {
|
|
|
+ self.updateGoogleAuthButtonTitle()
|
|
|
+ self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
|
|
|
+ self.pageCache[.joinMeetings] = nil
|
|
|
+ self.showSidebarPage(.joinMeetings)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ self.showSimpleError("Couldn’t connect Google account.", error: error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func showGoogleAccountMenu() {
|
|
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
+ let menu = NSMenu()
|
|
|
+
|
|
|
+ let name = scheduleCurrentProfile?.name ?? "Google account"
|
|
|
+ let email = scheduleCurrentProfile?.email ?? "Signed in"
|
|
|
+ let accountItem = NSMenuItem(title: "\(name) (\(email))", action: nil, keyEquivalent: "")
|
|
|
+ accountItem.isEnabled = false
|
|
|
+ menu.addItem(accountItem)
|
|
|
+ menu.addItem(.separator())
|
|
|
+
|
|
|
+ let logoutItem = NSMenuItem(title: "Logout", action: #selector(scheduleLogoutSelected(_:)), keyEquivalent: "")
|
|
|
+ logoutItem.target = self
|
|
|
+ menu.addItem(logoutItem)
|
|
|
+
|
|
|
+ let point = NSPoint(x: 0, y: button.bounds.height + 2)
|
|
|
+ menu.popUp(positioning: nil, at: point, in: button)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func scheduleLogoutSelected(_ sender: NSMenuItem) {
|
|
|
+ do {
|
|
|
+ try googleOAuth.signOut()
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+ applyGoogleProfile(nil)
|
|
|
+ scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
+ if let stack = scheduleCardsStack {
|
|
|
+ renderScheduleCards(into: stack, meetings: [])
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ showSimpleError("Couldn’t logout Google account.", error: error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func updateGoogleAuthButtonTitle() {
|
|
|
+ let signedIn = (googleOAuth.loadTokens() != nil)
|
|
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
+
|
|
|
+ let profileName = scheduleCurrentProfile?.name ?? "Google account"
|
|
|
+ let profileEmail = scheduleCurrentProfile?.email ?? "Sign in with Google"
|
|
|
+ let title = signedIn ? "\(profileName) · \(profileEmail)" : "Sign in with Google"
|
|
|
+ let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
|
|
|
+ let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
|
|
|
+ button.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
|
+ .font: titleFont,
|
|
|
+ .foregroundColor: titleColor
|
|
|
+ ])
|
|
|
+ let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
|
|
|
+ let idealWidth = ceil(textWidth + 80) // icon + spacing + side padding
|
|
|
+ scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
|
|
|
+
|
|
|
+ if signedIn {
|
|
|
+ let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
+ button.image = symbol.flatMap { paddedTrailingImage($0, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8) }
|
|
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
|
|
+ button.contentTintColor = palette.textPrimary
|
|
|
+ } else {
|
|
|
+ if let g = NSImage(named: "GoogleGLogo") {
|
|
|
+ button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
|
|
|
+ } else {
|
|
|
+ button.image = nil
|
|
|
+ }
|
|
|
+ button.contentTintColor = nil
|
|
|
+ }
|
|
|
+ button.contentTintColor = signedIn ? palette.textPrimary : nil
|
|
|
+
|
|
|
+ applyGoogleAuthButtonSurface()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
|
|
|
+ let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ return GoogleProfileDisplay(
|
|
|
+ name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
|
|
|
+ email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
|
|
|
+ pictureURL: profile.picture.flatMap(URL.init(string:))
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
|
+ scheduleProfileImageTask?.cancel()
|
|
|
+ scheduleProfileImageTask = nil
|
|
|
+ scheduleCurrentProfile = profile
|
|
|
+
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
+
|
|
|
+ guard let profile, let pictureURL = profile.pictureURL else { return }
|
|
|
+ scheduleProfileImageTask = Task { [weak self] in
|
|
|
+ do {
|
|
|
+ let (data, _) = try await URLSession.shared.data(from: pictureURL)
|
|
|
+ if Task.isCancelled { return }
|
|
|
+ guard let image = NSImage(data: data) else { return }
|
|
|
+ await MainActor.run {
|
|
|
+ self?.scheduleGoogleAuthButton?.image = self?.paddedTrailingImage(image, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
|
|
|
+ self?.scheduleGoogleAuthButton?.contentTintColor = nil
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // Keep placeholder avatar if image fetch fails.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
|
|
|
+ let result = NSImage(size: size)
|
|
|
+ result.lockFocus()
|
|
|
+ image.draw(in: NSRect(origin: .zero, size: size),
|
|
|
+ from: NSRect(origin: .zero, size: image.size),
|
|
|
+ operation: .copy,
|
|
|
+ fraction: 1.0)
|
|
|
+ result.unlockFocus()
|
|
|
+ result.isTemplate = false
|
|
|
+ return result
|
|
|
+ }
|
|
|
+
|
|
|
+ private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
|
|
|
+ let base = resizedImage(image, to: iconSize)
|
|
|
+ let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
|
|
|
+ let result = NSImage(size: canvas)
|
|
|
+ result.lockFocus()
|
|
|
+ base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
|
|
|
+ from: NSRect(origin: .zero, size: base.size),
|
|
|
+ operation: .copy,
|
|
|
+ fraction: 1.0)
|
|
|
+ result.unlockFocus()
|
|
|
+ result.isTemplate = false
|
|
|
+ return result
|
|
|
+ }
|
|
|
+
|
|
|
+ private func applyGoogleAuthButtonSurface() {
|
|
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
+ let isDark = darkModeEnabled
|
|
|
+ let baseBackground = isDark
|
|
|
+ ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
|
|
|
+ : NSColor.white
|
|
|
+ let hoverBlend = isDark ? NSColor.white : NSColor.black
|
|
|
+ let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
|
|
|
+ let baseBorder = isDark
|
|
|
+ ? NSColor(calibratedWhite: 0.50, alpha: 1)
|
|
|
+ : NSColor(calibratedWhite: 0.72, alpha: 1)
|
|
|
+ let hoverBorder = isDark
|
|
|
+ ? NSColor(calibratedWhite: 0.62, alpha: 1)
|
|
|
+ : NSColor(calibratedWhite: 0.56, alpha: 1)
|
|
|
+ button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
|
|
|
+ button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
|
|
|
+ if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
|
|
|
+
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = "Enter Google OAuth credentials"
|
|
|
+ alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
|
|
|
+
|
|
|
+ let accessory = NSStackView()
|
|
|
+ accessory.orientation = .vertical
|
|
|
+ accessory.spacing = 8
|
|
|
+ accessory.alignment = .leading
|
|
|
+
|
|
|
+ let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
|
|
|
+ idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
|
|
|
+ idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
|
|
|
+
|
|
|
+ let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
|
|
|
+ secretField.placeholderString = "Client Secret (GOCSPX-...)"
|
|
|
+ secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
|
|
|
+
|
|
|
+ accessory.addArrangedSubview(idField)
|
|
|
+ accessory.addArrangedSubview(secretField)
|
|
|
+ alert.accessoryView = accessory
|
|
|
+
|
|
|
+ alert.addButton(withTitle: "Save")
|
|
|
+ alert.addButton(withTitle: "Cancel")
|
|
|
+
|
|
|
+ // Keep this synchronous to avoid additional sheet state handling.
|
|
|
+ let response = alert.runModal()
|
|
|
+ if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
|
|
|
+
|
|
|
+ let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
|
|
|
+ if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
|
|
|
+
|
|
|
+ googleOAuth.setClientIdForTesting(idValue)
|
|
|
+ googleOAuth.setClientSecretForTesting(secretValue)
|
|
|
+ }
|
|
|
+
|
|
|
+ func showSimpleError(_ title: String, error: Error) {
|
|
|
+ DispatchQueue.main.async {
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.alertStyle = .warning
|
|
|
+ alert.messageText = title
|
|
|
+ alert.informativeText = error.localizedDescription
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
+ alert.runModal()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
private struct Palette {
|
|
|
let pageBackground: NSColor
|
|
|
let sidebarBackground: NSColor
|
|
|
@@ -2514,11 +3229,8 @@ private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -
|
|
|
}
|
|
|
|
|
|
private enum InAppBrowserWebKitSupport {
|
|
|
- static let sharedProcessPool = WKProcessPool()
|
|
|
-
|
|
|
static func makeWebViewConfiguration() -> WKWebViewConfiguration {
|
|
|
let config = WKWebViewConfiguration()
|
|
|
- config.processPool = sharedProcessPool
|
|
|
config.websiteDataStore = .default()
|
|
|
config.preferences.javaScriptCanOpenWindowsAutomatically = true
|
|
|
if #available(macOS 12.3, *) {
|