|
@@ -86,9 +86,15 @@ final class ViewController: NSViewController {
|
|
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
|
private weak var scheduleGoogleAuthButton: NSButton?
|
|
private weak var scheduleGoogleAuthButton: NSButton?
|
|
|
private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
|
|
private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
|
|
|
|
|
+ private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
|
|
|
|
|
+ /// Circular avatar size when signed in (top-right, Google-style).
|
|
|
|
|
+ private let scheduleGoogleSignedInAvatarSize: CGFloat = 36
|
|
|
private var scheduleGoogleAuthHovering = false
|
|
private var scheduleGoogleAuthHovering = false
|
|
|
private var scheduleCurrentProfile: GoogleProfileDisplay?
|
|
private var scheduleCurrentProfile: GoogleProfileDisplay?
|
|
|
|
|
+ /// Larger copy of the header avatar for the account popover (optional).
|
|
|
|
|
+ private var scheduleProfileMenuAvatar: NSImage?
|
|
|
private var scheduleProfileImageTask: Task<Void, Never>?
|
|
private var scheduleProfileImageTask: Task<Void, Never>?
|
|
|
|
|
+ private var googleAccountPopover: NSPopover?
|
|
|
|
|
|
|
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
|
|
@@ -436,6 +442,9 @@ private extension ViewController {
|
|
|
paywallPlanViews.removeAll()
|
|
paywallPlanViews.removeAll()
|
|
|
premiumPlanByView.removeAll()
|
|
premiumPlanByView.removeAll()
|
|
|
|
|
|
|
|
|
|
+ googleAccountPopover?.performClose(nil)
|
|
|
|
|
+ googleAccountPopover = nil
|
|
|
|
|
+
|
|
|
mainContentHost = nil
|
|
mainContentHost = nil
|
|
|
view.subviews.forEach { $0.removeFromSuperview() }
|
|
view.subviews.forEach { $0.removeFromSuperview() }
|
|
|
setupRootView()
|
|
setupRootView()
|
|
@@ -1910,7 +1919,10 @@ private extension ViewController {
|
|
|
button.lineBreakMode = .byTruncatingTail
|
|
button.lineBreakMode = .byTruncatingTail
|
|
|
button.contentTintColor = palette.textPrimary
|
|
button.contentTintColor = palette.textPrimary
|
|
|
button.imageScaling = .scaleNone
|
|
button.imageScaling = .scaleNone
|
|
|
- button.heightAnchor.constraint(equalToConstant: 42).isActive = true
|
|
|
|
|
|
|
+ button.layer?.masksToBounds = true
|
|
|
|
|
+ let heightConstraint = button.heightAnchor.constraint(equalToConstant: 42)
|
|
|
|
|
+ heightConstraint.isActive = true
|
|
|
|
|
+ scheduleGoogleAuthButtonHeightConstraint = heightConstraint
|
|
|
let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
|
|
let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
|
|
|
widthConstraint.isActive = true
|
|
widthConstraint.isActive = true
|
|
|
scheduleGoogleAuthButtonWidthConstraint = widthConstraint
|
|
scheduleGoogleAuthButtonWidthConstraint = widthConstraint
|
|
@@ -2293,6 +2305,259 @@ private final class HoverButton: NSButton {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+private func circularNSImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
|
|
|
|
|
+ let size = NSSize(width: diameter, height: diameter)
|
|
|
|
|
+ let result = NSImage(size: size)
|
|
|
|
|
+ result.lockFocus()
|
|
|
|
|
+ if let ctx = NSGraphicsContext.current {
|
|
|
|
|
+ ctx.imageInterpolation = .high
|
|
|
|
|
+ }
|
|
|
|
|
+ let rect = NSRect(origin: .zero, size: size)
|
|
|
|
|
+ let path = NSBezierPath(ovalIn: rect)
|
|
|
|
|
+ path.addClip()
|
|
|
|
|
+ let src = image.size.width > 0 && image.size.height > 0
|
|
|
|
|
+ ? NSRect(origin: .zero, size: image.size)
|
|
|
|
|
+ : NSRect(origin: .zero, size: size)
|
|
|
|
|
+ image.draw(in: rect, from: src, operation: .copy, fraction: 1.0)
|
|
|
|
|
+ result.unlockFocus()
|
|
|
|
|
+ result.isTemplate = false
|
|
|
|
|
+ return result
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private final class GoogleAccountMenuViewController: NSViewController {
|
|
|
|
|
+ private let palette: Palette
|
|
|
|
|
+ private let darkModeEnabled: Bool
|
|
|
|
|
+ private let displayName: String
|
|
|
|
|
+ private let email: String
|
|
|
|
|
+ private let avatar: NSImage?
|
|
|
|
|
+ private let onSignOut: () -> Void
|
|
|
|
|
+
|
|
|
|
|
+ init(
|
|
|
|
|
+ palette: Palette,
|
|
|
|
|
+ darkModeEnabled: Bool,
|
|
|
|
|
+ displayName: String,
|
|
|
|
|
+ email: String,
|
|
|
|
|
+ avatar: NSImage?,
|
|
|
|
|
+ onSignOut: @escaping () -> Void
|
|
|
|
|
+ ) {
|
|
|
|
|
+ self.palette = palette
|
|
|
|
|
+ self.darkModeEnabled = darkModeEnabled
|
|
|
|
|
+ self.displayName = displayName
|
|
|
|
|
+ self.email = email
|
|
|
|
|
+ self.avatar = avatar
|
|
|
|
|
+ self.onSignOut = onSignOut
|
|
|
|
|
+ super.init(nibName: nil, bundle: nil)
|
|
|
|
|
+ view = makeContentView()
|
|
|
|
|
+ view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
|
|
|
+ preferredContentSize = NSSize(width: 300, height: 158)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) {
|
|
|
|
|
+ nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makeContentView() -> NSView {
|
|
|
|
|
+ let root = NSView()
|
|
|
|
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let card = NSView()
|
|
|
|
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ card.wantsLayer = true
|
|
|
|
|
+ card.layer?.cornerRadius = 14
|
|
|
|
|
+ card.layer?.backgroundColor = palette.sectionCard.cgColor
|
|
|
|
|
+ card.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ card.layer?.borderWidth = 1
|
|
|
|
|
+ card.layer?.shadowColor = NSColor.black.cgColor
|
|
|
|
|
+ card.layer?.shadowOpacity = darkModeEnabled ? 0.5 : 0.2
|
|
|
|
|
+ card.layer?.shadowOffset = CGSize(width: 0, height: 6)
|
|
|
|
|
+ card.layer?.shadowRadius = 18
|
|
|
|
|
+ card.layer?.masksToBounds = false
|
|
|
|
|
+
|
|
|
|
|
+ root.addSubview(card)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ card.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
|
|
|
|
|
+ card.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
|
|
|
|
|
+ card.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
|
|
|
|
|
+ card.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
|
|
|
|
|
+ root.widthAnchor.constraint(equalToConstant: 300)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ let inner = NSStackView()
|
|
|
|
|
+ inner.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ inner.orientation = .vertical
|
|
|
|
|
+ inner.spacing = 0
|
|
|
|
|
+ inner.alignment = .leading
|
|
|
|
|
+ card.addSubview(inner)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ inner.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
|
|
|
|
|
+ inner.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
|
|
|
|
|
+ inner.topAnchor.constraint(equalTo: card.topAnchor, constant: 18),
|
|
|
|
|
+ inner.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ let headerRow = NSView()
|
|
|
|
|
+ headerRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let avatarBox = NSView()
|
|
|
|
|
+ avatarBox.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ avatarBox.wantsLayer = true
|
|
|
|
|
+ avatarBox.layer?.cornerRadius = 24
|
|
|
|
|
+ avatarBox.layer?.masksToBounds = true
|
|
|
|
|
+ avatarBox.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ avatarBox.layer?.borderWidth = 1
|
|
|
|
|
+
|
|
|
|
|
+ let avatarView = NSImageView()
|
|
|
|
|
+ avatarView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ avatarView.imageScaling = .scaleAxesIndependently
|
|
|
|
|
+ avatarView.image = resolvedAvatarImage()
|
|
|
|
|
+
|
|
|
|
|
+ avatarBox.addSubview(avatarView)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ avatarBox.widthAnchor.constraint(equalToConstant: 48),
|
|
|
|
|
+ avatarBox.heightAnchor.constraint(equalToConstant: 48),
|
|
|
|
|
+ avatarView.leadingAnchor.constraint(equalTo: avatarBox.leadingAnchor),
|
|
|
|
|
+ avatarView.trailingAnchor.constraint(equalTo: avatarBox.trailingAnchor),
|
|
|
|
|
+ avatarView.topAnchor.constraint(equalTo: avatarBox.topAnchor),
|
|
|
|
|
+ avatarView.bottomAnchor.constraint(equalTo: avatarBox.bottomAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ let textColumn = NSStackView()
|
|
|
|
|
+ textColumn.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ textColumn.orientation = .vertical
|
|
|
|
|
+ textColumn.spacing = 3
|
|
|
|
|
+ textColumn.alignment = .leading
|
|
|
|
|
+
|
|
|
|
|
+ let nameField = NSTextField(labelWithString: displayName)
|
|
|
|
|
+ nameField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ nameField.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
|
|
|
|
|
+ nameField.textColor = palette.textPrimary
|
|
|
|
|
+ nameField.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ nameField.maximumNumberOfLines = 1
|
|
|
|
|
+ nameField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ let emailField = NSTextField(labelWithString: email)
|
|
|
|
|
+ emailField.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ emailField.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
|
|
|
|
+ emailField.textColor = palette.textTertiary
|
|
|
|
|
+ emailField.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ emailField.maximumNumberOfLines = 1
|
|
|
|
|
+ emailField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+
|
|
|
|
|
+ textColumn.addArrangedSubview(nameField)
|
|
|
|
|
+ textColumn.addArrangedSubview(emailField)
|
|
|
|
|
+
|
|
|
|
|
+ headerRow.addSubview(avatarBox)
|
|
|
|
|
+ headerRow.addSubview(textColumn)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ avatarBox.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor),
|
|
|
|
|
+ avatarBox.topAnchor.constraint(equalTo: headerRow.topAnchor),
|
|
|
|
|
+ avatarBox.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ textColumn.leadingAnchor.constraint(equalTo: avatarBox.trailingAnchor, constant: 14),
|
|
|
|
|
+ textColumn.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor),
|
|
|
|
|
+ textColumn.centerYAnchor.constraint(equalTo: avatarBox.centerYAnchor),
|
|
|
|
|
+ textColumn.topAnchor.constraint(greaterThanOrEqualTo: headerRow.topAnchor),
|
|
|
|
|
+ textColumn.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ inner.addArrangedSubview(headerRow)
|
|
|
|
|
+ headerRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ inner.setCustomSpacing(14, after: headerRow)
|
|
|
|
|
+
|
|
|
|
|
+ let separator = NSView()
|
|
|
|
|
+ separator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ separator.wantsLayer = true
|
|
|
|
|
+ separator.layer?.backgroundColor = palette.separator.cgColor
|
|
|
|
|
+ separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
|
|
|
|
|
+ inner.addArrangedSubview(separator)
|
|
|
|
|
+ separator.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ inner.setCustomSpacing(6, after: separator)
|
|
|
|
|
+
|
|
|
|
|
+ let signOutRow = HoverTrackingView()
|
|
|
|
|
+ signOutRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ signOutRow.heightAnchor.constraint(equalToConstant: 44).isActive = true
|
|
|
|
|
+ signOutRow.wantsLayer = true
|
|
|
|
|
+ signOutRow.layer?.cornerRadius = 10
|
|
|
|
|
+
|
|
|
|
|
+ let signOutIcon = NSImageView()
|
|
|
|
|
+ signOutIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ signOutIcon.imageScaling = .scaleProportionallyDown
|
|
|
|
|
+ if let sym = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: nil) {
|
|
|
|
|
+ signOutIcon.image = sym
|
|
|
|
|
+ signOutIcon.contentTintColor = palette.textSecondary
|
|
|
|
|
+ signOutIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let signOutLabel = NSTextField(labelWithString: "Log out")
|
|
|
|
|
+ signOutLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ signOutLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
|
|
|
|
|
+ signOutLabel.textColor = palette.textPrimary
|
|
|
|
|
+
|
|
|
|
|
+ signOutRow.addSubview(signOutIcon)
|
|
|
|
|
+ signOutRow.addSubview(signOutLabel)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ signOutIcon.leadingAnchor.constraint(equalTo: signOutRow.leadingAnchor, constant: 10),
|
|
|
|
|
+ signOutIcon.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
|
|
|
|
|
+ signOutIcon.widthAnchor.constraint(equalToConstant: 20),
|
|
|
|
|
+ signOutIcon.heightAnchor.constraint(equalToConstant: 20),
|
|
|
|
|
+
|
|
|
|
|
+ signOutLabel.leadingAnchor.constraint(equalTo: signOutIcon.trailingAnchor, constant: 10),
|
|
|
|
|
+ signOutLabel.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
|
|
|
|
|
+ signOutLabel.trailingAnchor.constraint(lessThanOrEqualTo: signOutRow.trailingAnchor, constant: -10)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ let signOutClick = NSClickGestureRecognizer(target: self, action: #selector(signOutClicked))
|
|
|
|
|
+ signOutRow.addGestureRecognizer(signOutClick)
|
|
|
|
|
+
|
|
|
|
|
+ signOutRow.onHoverChanged = { [weak self] hovering in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ signOutRow.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
|
|
|
|
|
+ }
|
|
|
|
|
+ signOutRow.onHoverChanged?(false)
|
|
|
|
|
+
|
|
|
|
|
+ inner.addArrangedSubview(signOutRow)
|
|
|
|
|
+ signOutRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ return root
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func resolvedAvatarImage() -> NSImage {
|
|
|
|
|
+ if let a = avatar {
|
|
|
|
|
+ return circularNSImage(a, diameter: 48)
|
|
|
|
|
+ }
|
|
|
|
|
+ return initialLetterAvatar()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func initialLetterAvatar() -> NSImage {
|
|
|
|
|
+ let d: CGFloat = 48
|
|
|
|
|
+ let letter = displayName.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "?"
|
|
|
|
|
+ let img = NSImage(size: NSSize(width: d, height: d))
|
|
|
|
|
+ img.lockFocus()
|
|
|
|
|
+ palette.primaryBlue.setFill()
|
|
|
|
|
+ NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill()
|
|
|
|
|
+ let attrs: [NSAttributedString.Key: Any] = [
|
|
|
|
|
+ .font: NSFont.systemFont(ofSize: 20, weight: .semibold),
|
|
|
|
|
+ .foregroundColor: NSColor.white
|
|
|
|
|
+ ]
|
|
|
|
|
+ let sz = (letter as NSString).size(withAttributes: attrs)
|
|
|
|
|
+ let origin = NSPoint(x: (d - sz.width) / 2, y: (d - sz.height) / 2)
|
|
|
|
|
+ (letter as NSString).draw(at: origin, withAttributes: attrs)
|
|
|
|
|
+ img.unlockFocus()
|
|
|
|
|
+ img.isTemplate = false
|
|
|
|
|
+ return img
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func signOutClicked() {
|
|
|
|
|
+ onSignOut()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
private final class SettingsMenuViewController: NSViewController {
|
|
private final class SettingsMenuViewController: NSViewController {
|
|
|
private let palette: Palette
|
|
private let palette: Palette
|
|
|
private let typography: Typography
|
|
private let typography: Typography
|
|
@@ -2923,27 +3188,41 @@ private extension ViewController {
|
|
|
|
|
|
|
|
private func showGoogleAccountMenu() {
|
|
private func showGoogleAccountMenu() {
|
|
|
guard let button = scheduleGoogleAuthButton else { return }
|
|
guard let button = scheduleGoogleAuthButton else { return }
|
|
|
- let menu = NSMenu()
|
|
|
|
|
|
|
+ if googleAccountPopover?.isShown == true {
|
|
|
|
|
+ googleAccountPopover?.performClose(nil)
|
|
|
|
|
+ googleAccountPopover = nil
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let popover = NSPopover()
|
|
|
|
|
+ popover.behavior = .transient
|
|
|
|
|
+ popover.animates = true
|
|
|
|
|
+ popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
|
|
|
|
|
|
let name = scheduleCurrentProfile?.name ?? "Google account"
|
|
let name = scheduleCurrentProfile?.name ?? "Google account"
|
|
|
let email = scheduleCurrentProfile?.email ?? "Signed in"
|
|
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 avatar = scheduleProfileMenuAvatar
|
|
|
|
|
|
|
|
- let logoutItem = NSMenuItem(title: "Logout", action: #selector(scheduleLogoutSelected(_:)), keyEquivalent: "")
|
|
|
|
|
- logoutItem.target = self
|
|
|
|
|
- menu.addItem(logoutItem)
|
|
|
|
|
|
|
+ popover.contentViewController = GoogleAccountMenuViewController(
|
|
|
|
|
+ palette: palette,
|
|
|
|
|
+ darkModeEnabled: darkModeEnabled,
|
|
|
|
|
+ displayName: name,
|
|
|
|
|
+ email: email,
|
|
|
|
|
+ avatar: avatar,
|
|
|
|
|
+ onSignOut: { [weak self] in
|
|
|
|
|
+ self?.googleAccountPopover?.performClose(nil)
|
|
|
|
|
+ self?.googleAccountPopover = nil
|
|
|
|
|
+ self?.performGoogleSignOut()
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- let point = NSPoint(x: 0, y: button.bounds.height + 2)
|
|
|
|
|
- menu.popUp(positioning: nil, at: point, in: button)
|
|
|
|
|
|
|
+ googleAccountPopover = popover
|
|
|
|
|
+ popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- @objc private func scheduleLogoutSelected(_ sender: NSMenuItem) {
|
|
|
|
|
|
|
+ private func performGoogleSignOut() {
|
|
|
do {
|
|
do {
|
|
|
try googleOAuth.signOut()
|
|
try googleOAuth.signOut()
|
|
|
- updateGoogleAuthButtonTitle()
|
|
|
|
|
applyGoogleProfile(nil)
|
|
applyGoogleProfile(nil)
|
|
|
scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
if let stack = scheduleCardsStack {
|
|
if let stack = scheduleCardsStack {
|
|
@@ -2959,24 +3238,44 @@ private extension ViewController {
|
|
|
guard let button = scheduleGoogleAuthButton else { return }
|
|
guard let button = scheduleGoogleAuthButton else { return }
|
|
|
|
|
|
|
|
let profileName = scheduleCurrentProfile?.name ?? "Google account"
|
|
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 {
|
|
if signedIn {
|
|
|
|
|
+ button.setAccessibilityLabel("\(profileName), Google account")
|
|
|
|
|
+ button.attributedTitle = NSAttributedString(string: "")
|
|
|
|
|
+ button.imagePosition = .imageOnly
|
|
|
|
|
+ button.imageScaling = .scaleProportionallyDown
|
|
|
|
|
+ button.symbolConfiguration = nil
|
|
|
|
|
+ scheduleGoogleAuthButtonHeightConstraint?.constant = scheduleGoogleSignedInAvatarSize
|
|
|
|
|
+ scheduleGoogleAuthButtonWidthConstraint?.constant = scheduleGoogleSignedInAvatarSize
|
|
|
|
|
+ button.layer?.cornerRadius = scheduleGoogleSignedInAvatarSize / 2
|
|
|
|
|
+
|
|
|
let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
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
|
|
|
|
|
|
|
+ if let symbol {
|
|
|
|
|
+ let sized = resizedImage(symbol, to: NSSize(width: scheduleGoogleSignedInAvatarSize, height: scheduleGoogleSignedInAvatarSize))
|
|
|
|
|
+ button.image = sized
|
|
|
|
|
+ button.contentTintColor = palette.textSecondary
|
|
|
|
|
+ } else {
|
|
|
|
|
+ button.image = nil
|
|
|
|
|
+ button.contentTintColor = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ scheduleProfileMenuAvatar = button.image
|
|
|
} else {
|
|
} else {
|
|
|
|
|
+ button.setAccessibilityLabel("Sign in with Google")
|
|
|
|
|
+ let title = "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)
|
|
|
|
|
+ scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
|
|
|
|
|
+ scheduleGoogleAuthButtonHeightConstraint?.constant = 42
|
|
|
|
|
+ button.layer?.cornerRadius = 21
|
|
|
|
|
+
|
|
|
|
|
+ button.imagePosition = .imageLeading
|
|
|
|
|
+ button.imageScaling = .scaleNone
|
|
|
if let g = NSImage(named: "GoogleGLogo") {
|
|
if let g = NSImage(named: "GoogleGLogo") {
|
|
|
button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
|
|
button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
|
|
|
} else {
|
|
} else {
|
|
@@ -2984,7 +3283,6 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
button.contentTintColor = nil
|
|
button.contentTintColor = nil
|
|
|
}
|
|
}
|
|
|
- button.contentTintColor = signedIn ? palette.textPrimary : nil
|
|
|
|
|
|
|
|
|
|
applyGoogleAuthButtonSurface()
|
|
applyGoogleAuthButtonSurface()
|
|
|
}
|
|
}
|
|
@@ -3002,19 +3300,26 @@ private extension ViewController {
|
|
|
private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
|
scheduleProfileImageTask?.cancel()
|
|
scheduleProfileImageTask?.cancel()
|
|
|
scheduleProfileImageTask = nil
|
|
scheduleProfileImageTask = nil
|
|
|
|
|
+ if profile == nil {
|
|
|
|
|
+ scheduleProfileMenuAvatar = nil
|
|
|
|
|
+ }
|
|
|
scheduleCurrentProfile = profile
|
|
scheduleCurrentProfile = profile
|
|
|
|
|
|
|
|
updateGoogleAuthButtonTitle()
|
|
updateGoogleAuthButtonTitle()
|
|
|
|
|
|
|
|
guard let profile, let pictureURL = profile.pictureURL else { return }
|
|
guard let profile, let pictureURL = profile.pictureURL else { return }
|
|
|
|
|
+ let avatarDiameter = scheduleGoogleSignedInAvatarSize
|
|
|
scheduleProfileImageTask = Task { [weak self] in
|
|
scheduleProfileImageTask = Task { [weak self] in
|
|
|
do {
|
|
do {
|
|
|
let (data, _) = try await URLSession.shared.data(from: pictureURL)
|
|
let (data, _) = try await URLSession.shared.data(from: pictureURL)
|
|
|
if Task.isCancelled { return }
|
|
if Task.isCancelled { return }
|
|
|
guard let image = NSImage(data: data) else { 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
|
|
|
|
|
|
|
+ await MainActor.run { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ let rounded = self.circularProfileImage(image, diameter: avatarDiameter)
|
|
|
|
|
+ self.scheduleProfileMenuAvatar = circularNSImage(rounded, diameter: 48)
|
|
|
|
|
+ self.scheduleGoogleAuthButton?.image = rounded
|
|
|
|
|
+ self.scheduleGoogleAuthButton?.contentTintColor = nil
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
// Keep placeholder avatar if image fetch fails.
|
|
// Keep placeholder avatar if image fetch fails.
|
|
@@ -3034,6 +3339,11 @@ private extension ViewController {
|
|
|
return result
|
|
return result
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Clips a photo to a circle for the signed-in avatar (Google userinfo `picture` URLs are usually square).
|
|
|
|
|
+ private func circularProfileImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
|
|
|
|
|
+ circularNSImage(image, diameter: diameter)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
|
|
private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
|
|
|
let base = resizedImage(image, to: iconSize)
|
|
let base = resizedImage(image, to: iconSize)
|
|
|
let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
|
|
let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
|