Bladeren bron

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 1 week geleden
bovenliggende
commit
90dfc5e3e1
1 gewijzigde bestanden met toevoegingen van 341 en 31 verwijderingen
  1. 341 31
      meetings_app/ViewController.swift

+ 341 - 31
meetings_app/ViewController.swift

@@ -86,9 +86,15 @@ final class ViewController: NSViewController {
86 86
     private weak var scheduleFilterDropdown: NSPopUpButton?
87 87
     private weak var scheduleGoogleAuthButton: NSButton?
88 88
     private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
89
+    private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
90
+    /// Circular avatar size when signed in (top-right, Google-style).
91
+    private let scheduleGoogleSignedInAvatarSize: CGFloat = 36
89 92
     private var scheduleGoogleAuthHovering = false
90 93
     private var scheduleCurrentProfile: GoogleProfileDisplay?
94
+    /// Larger copy of the header avatar for the account popover (optional).
95
+    private var scheduleProfileMenuAvatar: NSImage?
91 96
     private var scheduleProfileImageTask: Task<Void, Never>?
97
+    private var googleAccountPopover: NSPopover?
92 98
 
93 99
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
94 100
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -436,6 +442,9 @@ private extension ViewController {
436 442
         paywallPlanViews.removeAll()
437 443
         premiumPlanByView.removeAll()
438 444
 
445
+        googleAccountPopover?.performClose(nil)
446
+        googleAccountPopover = nil
447
+
439 448
         mainContentHost = nil
440 449
         view.subviews.forEach { $0.removeFromSuperview() }
441 450
         setupRootView()
@@ -1910,7 +1919,10 @@ private extension ViewController {
1910 1919
         button.lineBreakMode = .byTruncatingTail
1911 1920
         button.contentTintColor = palette.textPrimary
1912 1921
         button.imageScaling = .scaleNone
1913
-        button.heightAnchor.constraint(equalToConstant: 42).isActive = true
1922
+        button.layer?.masksToBounds = true
1923
+        let heightConstraint = button.heightAnchor.constraint(equalToConstant: 42)
1924
+        heightConstraint.isActive = true
1925
+        scheduleGoogleAuthButtonHeightConstraint = heightConstraint
1914 1926
         let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
1915 1927
         widthConstraint.isActive = true
1916 1928
         scheduleGoogleAuthButtonWidthConstraint = widthConstraint
@@ -2293,6 +2305,259 @@ private final class HoverButton: NSButton {
2293 2305
     }
2294 2306
 }
2295 2307
 
2308
+private func circularNSImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
2309
+    let size = NSSize(width: diameter, height: diameter)
2310
+    let result = NSImage(size: size)
2311
+    result.lockFocus()
2312
+    if let ctx = NSGraphicsContext.current {
2313
+        ctx.imageInterpolation = .high
2314
+    }
2315
+    let rect = NSRect(origin: .zero, size: size)
2316
+    let path = NSBezierPath(ovalIn: rect)
2317
+    path.addClip()
2318
+    let src = image.size.width > 0 && image.size.height > 0
2319
+        ? NSRect(origin: .zero, size: image.size)
2320
+        : NSRect(origin: .zero, size: size)
2321
+    image.draw(in: rect, from: src, operation: .copy, fraction: 1.0)
2322
+    result.unlockFocus()
2323
+    result.isTemplate = false
2324
+    return result
2325
+}
2326
+
2327
+private final class GoogleAccountMenuViewController: NSViewController {
2328
+    private let palette: Palette
2329
+    private let darkModeEnabled: Bool
2330
+    private let displayName: String
2331
+    private let email: String
2332
+    private let avatar: NSImage?
2333
+    private let onSignOut: () -> Void
2334
+
2335
+    init(
2336
+        palette: Palette,
2337
+        darkModeEnabled: Bool,
2338
+        displayName: String,
2339
+        email: String,
2340
+        avatar: NSImage?,
2341
+        onSignOut: @escaping () -> Void
2342
+    ) {
2343
+        self.palette = palette
2344
+        self.darkModeEnabled = darkModeEnabled
2345
+        self.displayName = displayName
2346
+        self.email = email
2347
+        self.avatar = avatar
2348
+        self.onSignOut = onSignOut
2349
+        super.init(nibName: nil, bundle: nil)
2350
+        view = makeContentView()
2351
+        view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
2352
+        preferredContentSize = NSSize(width: 300, height: 158)
2353
+    }
2354
+
2355
+    @available(*, unavailable)
2356
+    required init?(coder: NSCoder) {
2357
+        nil
2358
+    }
2359
+
2360
+    private func makeContentView() -> NSView {
2361
+        let root = NSView()
2362
+        root.translatesAutoresizingMaskIntoConstraints = false
2363
+
2364
+        let card = NSView()
2365
+        card.translatesAutoresizingMaskIntoConstraints = false
2366
+        card.wantsLayer = true
2367
+        card.layer?.cornerRadius = 14
2368
+        card.layer?.backgroundColor = palette.sectionCard.cgColor
2369
+        card.layer?.borderColor = palette.inputBorder.cgColor
2370
+        card.layer?.borderWidth = 1
2371
+        card.layer?.shadowColor = NSColor.black.cgColor
2372
+        card.layer?.shadowOpacity = darkModeEnabled ? 0.5 : 0.2
2373
+        card.layer?.shadowOffset = CGSize(width: 0, height: 6)
2374
+        card.layer?.shadowRadius = 18
2375
+        card.layer?.masksToBounds = false
2376
+
2377
+        root.addSubview(card)
2378
+        NSLayoutConstraint.activate([
2379
+            card.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
2380
+            card.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
2381
+            card.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
2382
+            card.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
2383
+            root.widthAnchor.constraint(equalToConstant: 300)
2384
+        ])
2385
+
2386
+        let inner = NSStackView()
2387
+        inner.translatesAutoresizingMaskIntoConstraints = false
2388
+        inner.orientation = .vertical
2389
+        inner.spacing = 0
2390
+        inner.alignment = .leading
2391
+        card.addSubview(inner)
2392
+
2393
+        NSLayoutConstraint.activate([
2394
+            inner.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
2395
+            inner.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
2396
+            inner.topAnchor.constraint(equalTo: card.topAnchor, constant: 18),
2397
+            inner.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
2398
+        ])
2399
+
2400
+        let headerRow = NSView()
2401
+        headerRow.translatesAutoresizingMaskIntoConstraints = false
2402
+
2403
+        let avatarBox = NSView()
2404
+        avatarBox.translatesAutoresizingMaskIntoConstraints = false
2405
+        avatarBox.wantsLayer = true
2406
+        avatarBox.layer?.cornerRadius = 24
2407
+        avatarBox.layer?.masksToBounds = true
2408
+        avatarBox.layer?.borderColor = palette.inputBorder.cgColor
2409
+        avatarBox.layer?.borderWidth = 1
2410
+
2411
+        let avatarView = NSImageView()
2412
+        avatarView.translatesAutoresizingMaskIntoConstraints = false
2413
+        avatarView.imageScaling = .scaleAxesIndependently
2414
+        avatarView.image = resolvedAvatarImage()
2415
+
2416
+        avatarBox.addSubview(avatarView)
2417
+        NSLayoutConstraint.activate([
2418
+            avatarBox.widthAnchor.constraint(equalToConstant: 48),
2419
+            avatarBox.heightAnchor.constraint(equalToConstant: 48),
2420
+            avatarView.leadingAnchor.constraint(equalTo: avatarBox.leadingAnchor),
2421
+            avatarView.trailingAnchor.constraint(equalTo: avatarBox.trailingAnchor),
2422
+            avatarView.topAnchor.constraint(equalTo: avatarBox.topAnchor),
2423
+            avatarView.bottomAnchor.constraint(equalTo: avatarBox.bottomAnchor)
2424
+        ])
2425
+
2426
+        let textColumn = NSStackView()
2427
+        textColumn.translatesAutoresizingMaskIntoConstraints = false
2428
+        textColumn.orientation = .vertical
2429
+        textColumn.spacing = 3
2430
+        textColumn.alignment = .leading
2431
+
2432
+        let nameField = NSTextField(labelWithString: displayName)
2433
+        nameField.translatesAutoresizingMaskIntoConstraints = false
2434
+        nameField.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
2435
+        nameField.textColor = palette.textPrimary
2436
+        nameField.lineBreakMode = .byTruncatingTail
2437
+        nameField.maximumNumberOfLines = 1
2438
+        nameField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2439
+
2440
+        let emailField = NSTextField(labelWithString: email)
2441
+        emailField.translatesAutoresizingMaskIntoConstraints = false
2442
+        emailField.font = NSFont.systemFont(ofSize: 12, weight: .regular)
2443
+        emailField.textColor = palette.textTertiary
2444
+        emailField.lineBreakMode = .byTruncatingTail
2445
+        emailField.maximumNumberOfLines = 1
2446
+        emailField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2447
+
2448
+        textColumn.addArrangedSubview(nameField)
2449
+        textColumn.addArrangedSubview(emailField)
2450
+
2451
+        headerRow.addSubview(avatarBox)
2452
+        headerRow.addSubview(textColumn)
2453
+
2454
+        NSLayoutConstraint.activate([
2455
+            avatarBox.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor),
2456
+            avatarBox.topAnchor.constraint(equalTo: headerRow.topAnchor),
2457
+            avatarBox.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor),
2458
+
2459
+            textColumn.leadingAnchor.constraint(equalTo: avatarBox.trailingAnchor, constant: 14),
2460
+            textColumn.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor),
2461
+            textColumn.centerYAnchor.constraint(equalTo: avatarBox.centerYAnchor),
2462
+            textColumn.topAnchor.constraint(greaterThanOrEqualTo: headerRow.topAnchor),
2463
+            textColumn.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor)
2464
+        ])
2465
+
2466
+        inner.addArrangedSubview(headerRow)
2467
+        headerRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
2468
+
2469
+        inner.setCustomSpacing(14, after: headerRow)
2470
+
2471
+        let separator = NSView()
2472
+        separator.translatesAutoresizingMaskIntoConstraints = false
2473
+        separator.wantsLayer = true
2474
+        separator.layer?.backgroundColor = palette.separator.cgColor
2475
+        separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
2476
+        inner.addArrangedSubview(separator)
2477
+        separator.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
2478
+
2479
+        inner.setCustomSpacing(6, after: separator)
2480
+
2481
+        let signOutRow = HoverTrackingView()
2482
+        signOutRow.translatesAutoresizingMaskIntoConstraints = false
2483
+        signOutRow.heightAnchor.constraint(equalToConstant: 44).isActive = true
2484
+        signOutRow.wantsLayer = true
2485
+        signOutRow.layer?.cornerRadius = 10
2486
+
2487
+        let signOutIcon = NSImageView()
2488
+        signOutIcon.translatesAutoresizingMaskIntoConstraints = false
2489
+        signOutIcon.imageScaling = .scaleProportionallyDown
2490
+        if let sym = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: nil) {
2491
+            signOutIcon.image = sym
2492
+            signOutIcon.contentTintColor = palette.textSecondary
2493
+            signOutIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
2494
+        }
2495
+
2496
+        let signOutLabel = NSTextField(labelWithString: "Log out")
2497
+        signOutLabel.translatesAutoresizingMaskIntoConstraints = false
2498
+        signOutLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
2499
+        signOutLabel.textColor = palette.textPrimary
2500
+
2501
+        signOutRow.addSubview(signOutIcon)
2502
+        signOutRow.addSubview(signOutLabel)
2503
+
2504
+        NSLayoutConstraint.activate([
2505
+            signOutIcon.leadingAnchor.constraint(equalTo: signOutRow.leadingAnchor, constant: 10),
2506
+            signOutIcon.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
2507
+            signOutIcon.widthAnchor.constraint(equalToConstant: 20),
2508
+            signOutIcon.heightAnchor.constraint(equalToConstant: 20),
2509
+
2510
+            signOutLabel.leadingAnchor.constraint(equalTo: signOutIcon.trailingAnchor, constant: 10),
2511
+            signOutLabel.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
2512
+            signOutLabel.trailingAnchor.constraint(lessThanOrEqualTo: signOutRow.trailingAnchor, constant: -10)
2513
+        ])
2514
+
2515
+        let signOutClick = NSClickGestureRecognizer(target: self, action: #selector(signOutClicked))
2516
+        signOutRow.addGestureRecognizer(signOutClick)
2517
+
2518
+        signOutRow.onHoverChanged = { [weak self] hovering in
2519
+            guard let self else { return }
2520
+            signOutRow.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
2521
+        }
2522
+        signOutRow.onHoverChanged?(false)
2523
+
2524
+        inner.addArrangedSubview(signOutRow)
2525
+        signOutRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
2526
+
2527
+        return root
2528
+    }
2529
+
2530
+    private func resolvedAvatarImage() -> NSImage {
2531
+        if let a = avatar {
2532
+            return circularNSImage(a, diameter: 48)
2533
+        }
2534
+        return initialLetterAvatar()
2535
+    }
2536
+
2537
+    private func initialLetterAvatar() -> NSImage {
2538
+        let d: CGFloat = 48
2539
+        let letter = displayName.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "?"
2540
+        let img = NSImage(size: NSSize(width: d, height: d))
2541
+        img.lockFocus()
2542
+        palette.primaryBlue.setFill()
2543
+        NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill()
2544
+        let attrs: [NSAttributedString.Key: Any] = [
2545
+            .font: NSFont.systemFont(ofSize: 20, weight: .semibold),
2546
+            .foregroundColor: NSColor.white
2547
+        ]
2548
+        let sz = (letter as NSString).size(withAttributes: attrs)
2549
+        let origin = NSPoint(x: (d - sz.width) / 2, y: (d - sz.height) / 2)
2550
+        (letter as NSString).draw(at: origin, withAttributes: attrs)
2551
+        img.unlockFocus()
2552
+        img.isTemplate = false
2553
+        return img
2554
+    }
2555
+
2556
+    @objc private func signOutClicked() {
2557
+        onSignOut()
2558
+    }
2559
+}
2560
+
2296 2561
 private final class SettingsMenuViewController: NSViewController {
2297 2562
     private let palette: Palette
2298 2563
     private let typography: Typography
@@ -2923,27 +3188,41 @@ private extension ViewController {
2923 3188
 
2924 3189
     private func showGoogleAccountMenu() {
2925 3190
         guard let button = scheduleGoogleAuthButton else { return }
2926
-        let menu = NSMenu()
3191
+        if googleAccountPopover?.isShown == true {
3192
+            googleAccountPopover?.performClose(nil)
3193
+            googleAccountPopover = nil
3194
+            return
3195
+        }
3196
+
3197
+        let popover = NSPopover()
3198
+        popover.behavior = .transient
3199
+        popover.animates = true
3200
+        popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
2927 3201
 
2928 3202
         let name = scheduleCurrentProfile?.name ?? "Google account"
2929 3203
         let email = scheduleCurrentProfile?.email ?? "Signed in"
2930
-        let accountItem = NSMenuItem(title: "\(name) (\(email))", action: nil, keyEquivalent: "")
2931
-        accountItem.isEnabled = false
2932
-        menu.addItem(accountItem)
2933
-        menu.addItem(.separator())
3204
+        let avatar = scheduleProfileMenuAvatar
2934 3205
 
2935
-        let logoutItem = NSMenuItem(title: "Logout", action: #selector(scheduleLogoutSelected(_:)), keyEquivalent: "")
2936
-        logoutItem.target = self
2937
-        menu.addItem(logoutItem)
3206
+        popover.contentViewController = GoogleAccountMenuViewController(
3207
+            palette: palette,
3208
+            darkModeEnabled: darkModeEnabled,
3209
+            displayName: name,
3210
+            email: email,
3211
+            avatar: avatar,
3212
+            onSignOut: { [weak self] in
3213
+                self?.googleAccountPopover?.performClose(nil)
3214
+                self?.googleAccountPopover = nil
3215
+                self?.performGoogleSignOut()
3216
+            }
3217
+        )
2938 3218
 
2939
-        let point = NSPoint(x: 0, y: button.bounds.height + 2)
2940
-        menu.popUp(positioning: nil, at: point, in: button)
3219
+        googleAccountPopover = popover
3220
+        popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
2941 3221
     }
2942 3222
 
2943
-    @objc private func scheduleLogoutSelected(_ sender: NSMenuItem) {
3223
+    private func performGoogleSignOut() {
2944 3224
         do {
2945 3225
             try googleOAuth.signOut()
2946
-            updateGoogleAuthButtonTitle()
2947 3226
             applyGoogleProfile(nil)
2948 3227
             scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
2949 3228
             if let stack = scheduleCardsStack {
@@ -2959,24 +3238,44 @@ private extension ViewController {
2959 3238
         guard let button = scheduleGoogleAuthButton else { return }
2960 3239
 
2961 3240
         let profileName = scheduleCurrentProfile?.name ?? "Google account"
2962
-        let profileEmail = scheduleCurrentProfile?.email ?? "Sign in with Google"
2963
-        let title = signedIn ? "\(profileName)  ·  \(profileEmail)" : "Sign in with Google"
2964
-        let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
2965
-        let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
2966
-        button.attributedTitle = NSAttributedString(string: title, attributes: [
2967
-            .font: titleFont,
2968
-            .foregroundColor: titleColor
2969
-        ])
2970
-        let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
2971
-        let idealWidth = ceil(textWidth + 80) // icon + spacing + side padding
2972
-        scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
2973 3241
 
2974 3242
         if signedIn {
3243
+            button.setAccessibilityLabel("\(profileName), Google account")
3244
+            button.attributedTitle = NSAttributedString(string: "")
3245
+            button.imagePosition = .imageOnly
3246
+            button.imageScaling = .scaleProportionallyDown
3247
+            button.symbolConfiguration = nil
3248
+            scheduleGoogleAuthButtonHeightConstraint?.constant = scheduleGoogleSignedInAvatarSize
3249
+            scheduleGoogleAuthButtonWidthConstraint?.constant = scheduleGoogleSignedInAvatarSize
3250
+            button.layer?.cornerRadius = scheduleGoogleSignedInAvatarSize / 2
3251
+
2975 3252
             let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
2976
-            button.image = symbol.flatMap { paddedTrailingImage($0, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8) }
2977
-            button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 22, weight: .regular)
2978
-            button.contentTintColor = palette.textPrimary
3253
+            if let symbol {
3254
+                let sized = resizedImage(symbol, to: NSSize(width: scheduleGoogleSignedInAvatarSize, height: scheduleGoogleSignedInAvatarSize))
3255
+                button.image = sized
3256
+                button.contentTintColor = palette.textSecondary
3257
+            } else {
3258
+                button.image = nil
3259
+                button.contentTintColor = nil
3260
+            }
3261
+            scheduleProfileMenuAvatar = button.image
2979 3262
         } else {
3263
+            button.setAccessibilityLabel("Sign in with Google")
3264
+            let title = "Sign in with Google"
3265
+            let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
3266
+            let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
3267
+            button.attributedTitle = NSAttributedString(string: title, attributes: [
3268
+                .font: titleFont,
3269
+                .foregroundColor: titleColor
3270
+            ])
3271
+            let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
3272
+            let idealWidth = ceil(textWidth + 80)
3273
+            scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
3274
+            scheduleGoogleAuthButtonHeightConstraint?.constant = 42
3275
+            button.layer?.cornerRadius = 21
3276
+
3277
+            button.imagePosition = .imageLeading
3278
+            button.imageScaling = .scaleNone
2980 3279
             if let g = NSImage(named: "GoogleGLogo") {
2981 3280
                 button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
2982 3281
             } else {
@@ -2984,7 +3283,6 @@ private extension ViewController {
2984 3283
             }
2985 3284
             button.contentTintColor = nil
2986 3285
         }
2987
-        button.contentTintColor = signedIn ? palette.textPrimary : nil
2988 3286
 
2989 3287
         applyGoogleAuthButtonSurface()
2990 3288
     }
@@ -3002,19 +3300,26 @@ private extension ViewController {
3002 3300
     private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
3003 3301
         scheduleProfileImageTask?.cancel()
3004 3302
         scheduleProfileImageTask = nil
3303
+        if profile == nil {
3304
+            scheduleProfileMenuAvatar = nil
3305
+        }
3005 3306
         scheduleCurrentProfile = profile
3006 3307
 
3007 3308
         updateGoogleAuthButtonTitle()
3008 3309
 
3009 3310
         guard let profile, let pictureURL = profile.pictureURL else { return }
3311
+        let avatarDiameter = scheduleGoogleSignedInAvatarSize
3010 3312
         scheduleProfileImageTask = Task { [weak self] in
3011 3313
             do {
3012 3314
                 let (data, _) = try await URLSession.shared.data(from: pictureURL)
3013 3315
                 if Task.isCancelled { return }
3014 3316
                 guard let image = NSImage(data: data) else { return }
3015
-                await MainActor.run {
3016
-                    self?.scheduleGoogleAuthButton?.image = self?.paddedTrailingImage(image, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
3017
-                    self?.scheduleGoogleAuthButton?.contentTintColor = nil
3317
+                await MainActor.run { [weak self] in
3318
+                    guard let self else { return }
3319
+                    let rounded = self.circularProfileImage(image, diameter: avatarDiameter)
3320
+                    self.scheduleProfileMenuAvatar = circularNSImage(rounded, diameter: 48)
3321
+                    self.scheduleGoogleAuthButton?.image = rounded
3322
+                    self.scheduleGoogleAuthButton?.contentTintColor = nil
3018 3323
                 }
3019 3324
             } catch {
3020 3325
                 // Keep placeholder avatar if image fetch fails.
@@ -3034,6 +3339,11 @@ private extension ViewController {
3034 3339
         return result
3035 3340
     }
3036 3341
 
3342
+    /// Clips a photo to a circle for the signed-in avatar (Google userinfo `picture` URLs are usually square).
3343
+    private func circularProfileImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
3344
+        circularNSImage(image, diameter: diameter)
3345
+    }
3346
+
3037 3347
     private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
3038 3348
         let base = resizedImage(image, to: iconSize)
3039 3349
         let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)