Ver Fonte

Polish Google sign-in UI: circular avatar and account popover

Show a compact circular profile image from Google userinfo when signed in,
and replace the system account menu with a custom NSPopover that matches the
app palette (name/email hierarchy, avatar, divider, hoverable Log out row).

Made-with: Cursor
huzaifahayat12 há 3 meses atrás
pai
commit
90dfc5e3e1
1 ficheiros alterados com 341 adições e 31 exclusões
  1. 341 31
      meetings_app/ViewController.swift

+ 341 - 31
meetings_app/ViewController.swift

@@ -86,9 +86,15 @@ final class ViewController: NSViewController {
     private weak var scheduleFilterDropdown: NSPopUpButton?
     private weak var scheduleGoogleAuthButton: NSButton?
     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 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 googleAccountPopover: NSPopover?
 
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -436,6 +442,9 @@ private extension ViewController {
         paywallPlanViews.removeAll()
         premiumPlanByView.removeAll()
 
+        googleAccountPopover?.performClose(nil)
+        googleAccountPopover = nil
+
         mainContentHost = nil
         view.subviews.forEach { $0.removeFromSuperview() }
         setupRootView()
@@ -1910,7 +1919,10 @@ private extension ViewController {
         button.lineBreakMode = .byTruncatingTail
         button.contentTintColor = palette.textPrimary
         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)
         widthConstraint.isActive = true
         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 let palette: Palette
     private let typography: Typography
@@ -2923,27 +3188,41 @@ private extension ViewController {
 
     private func showGoogleAccountMenu() {
         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 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 {
             try googleOAuth.signOut()
-            updateGoogleAuthButtonTitle()
             applyGoogleProfile(nil)
             scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
             if let stack = scheduleCardsStack {
@@ -2959,24 +3238,44 @@ private extension ViewController {
         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 {
+            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")
-            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 {
+            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") {
                 button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
             } else {
@@ -2984,7 +3283,6 @@ private extension ViewController {
             }
             button.contentTintColor = nil
         }
-        button.contentTintColor = signedIn ? palette.textPrimary : nil
 
         applyGoogleAuthButtonSurface()
     }
@@ -3002,19 +3300,26 @@ private extension ViewController {
     private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
         scheduleProfileImageTask?.cancel()
         scheduleProfileImageTask = nil
+        if profile == nil {
+            scheduleProfileMenuAvatar = nil
+        }
         scheduleCurrentProfile = profile
 
         updateGoogleAuthButtonTitle()
 
         guard let profile, let pictureURL = profile.pictureURL else { return }
+        let avatarDiameter = scheduleGoogleSignedInAvatarSize
         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
+                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 {
                 // Keep placeholder avatar if image fetch fails.
@@ -3034,6 +3339,11 @@ private extension ViewController {
         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 {
         let base = resizedImage(image, to: iconSize)
         let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)