|
@@ -85,10 +85,8 @@ final class ViewController: NSViewController {
|
|
|
private weak var scheduleScrollRightButton: NSView?
|
|
private weak var scheduleScrollRightButton: NSView?
|
|
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
|
private weak var scheduleGoogleAuthButton: NSButton?
|
|
private weak var scheduleGoogleAuthButton: NSButton?
|
|
|
- private weak var scheduleProfileContainer: NSView?
|
|
|
|
|
- private weak var scheduleProfileNameLabel: NSTextField?
|
|
|
|
|
- private weak var scheduleProfileEmailLabel: NSTextField?
|
|
|
|
|
- private weak var scheduleProfileImageView: NSImageView?
|
|
|
|
|
|
|
+ private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
|
|
|
|
|
+ private var scheduleCurrentProfile: GoogleProfileDisplay?
|
|
|
private var scheduleProfileImageTask: Task<Void, Never>?
|
|
private var scheduleProfileImageTask: Task<Void, Never>?
|
|
|
|
|
|
|
|
/// 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`).
|
|
@@ -932,6 +930,11 @@ private extension ViewController {
|
|
|
contentStack.spacing = 14
|
|
contentStack.spacing = 14
|
|
|
contentStack.alignment = .leading
|
|
contentStack.alignment = .leading
|
|
|
|
|
|
|
|
|
|
+ contentStack.addArrangedSubview(scheduleTopAuthRow())
|
|
|
|
|
+ if let authRow = contentStack.arrangedSubviews.last {
|
|
|
|
|
+ contentStack.setCustomSpacing(20, after: authRow)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
let joinActions = meetJoinActionsRow()
|
|
let joinActions = meetJoinActionsRow()
|
|
|
contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
|
|
contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
|
|
|
contentStack.addArrangedSubview(meetJoinSectionRow())
|
|
contentStack.addArrangedSubview(meetJoinSectionRow())
|
|
@@ -1817,18 +1820,28 @@ private extension ViewController {
|
|
|
|
|
|
|
|
row.addArrangedSubview(makeScheduleRefreshButton())
|
|
row.addArrangedSubview(makeScheduleRefreshButton())
|
|
|
|
|
|
|
|
- let profileBadge = makeScheduleProfileBadge()
|
|
|
|
|
- scheduleProfileContainer = profileBadge
|
|
|
|
|
- row.addArrangedSubview(profileBadge)
|
|
|
|
|
|
|
+ row.addArrangedSubview(makeScheduleFilterDropdown())
|
|
|
|
|
+ row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
|
|
+ return row
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Login with Google" : "Logout")
|
|
|
|
|
- connectButton.target = self
|
|
|
|
|
- connectButton.action = #selector(scheduleConnectButtonPressed(_:))
|
|
|
|
|
- scheduleGoogleAuthButton = connectButton
|
|
|
|
|
|
|
+ let authButton = makeGoogleAuthButton()
|
|
|
|
|
+ scheduleGoogleAuthButton = authButton
|
|
|
updateGoogleAuthButtonTitle()
|
|
updateGoogleAuthButtonTitle()
|
|
|
- row.addArrangedSubview(connectButton)
|
|
|
|
|
|
|
+ row.addArrangedSubview(authButton)
|
|
|
|
|
|
|
|
- row.addArrangedSubview(makeScheduleFilterDropdown())
|
|
|
|
|
row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
return row
|
|
return row
|
|
|
}
|
|
}
|
|
@@ -1881,52 +1894,26 @@ private extension ViewController {
|
|
|
return button
|
|
return button
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func makeScheduleProfileBadge() -> NSView {
|
|
|
|
|
- let chip = roundedContainer(cornerRadius: 16, color: palette.inputBackground)
|
|
|
|
|
- chip.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- styleSurface(chip, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
- chip.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
|
|
|
- chip.widthAnchor.constraint(equalToConstant: 230).isActive = true
|
|
|
|
|
- chip.isHidden = true
|
|
|
|
|
-
|
|
|
|
|
- let avatar = NSImageView()
|
|
|
|
|
- avatar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- avatar.wantsLayer = true
|
|
|
|
|
- avatar.layer?.cornerRadius = 11
|
|
|
|
|
- avatar.layer?.masksToBounds = true
|
|
|
|
|
- avatar.imageScaling = .scaleAxesIndependently
|
|
|
|
|
- avatar.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
|
|
|
|
- avatar.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
|
|
|
- avatar.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
|
|
- avatar.contentTintColor = palette.textSecondary
|
|
|
|
|
-
|
|
|
|
|
- let name = textLabel("Google User", font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
|
|
|
|
|
- let email = textLabel("Signed in", font: NSFont.systemFont(ofSize: 10, weight: .regular), color: palette.textMuted)
|
|
|
|
|
- name.lineBreakMode = .byTruncatingTail
|
|
|
|
|
- email.lineBreakMode = .byTruncatingTail
|
|
|
|
|
- name.maximumNumberOfLines = 1
|
|
|
|
|
- email.maximumNumberOfLines = 1
|
|
|
|
|
-
|
|
|
|
|
- let textStack = NSStackView(views: [name, email])
|
|
|
|
|
- textStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- textStack.orientation = .vertical
|
|
|
|
|
- textStack.spacing = 0
|
|
|
|
|
- textStack.alignment = .leading
|
|
|
|
|
-
|
|
|
|
|
- chip.addSubview(avatar)
|
|
|
|
|
- chip.addSubview(textStack)
|
|
|
|
|
- NSLayoutConstraint.activate([
|
|
|
|
|
- avatar.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 8),
|
|
|
|
|
- avatar.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
|
|
|
|
|
- textStack.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 7),
|
|
|
|
|
- textStack.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -8),
|
|
|
|
|
- textStack.centerYAnchor.constraint(equalTo: chip.centerYAnchor)
|
|
|
|
|
- ])
|
|
|
|
|
-
|
|
|
|
|
- scheduleProfileNameLabel = name
|
|
|
|
|
- scheduleProfileEmailLabel = email
|
|
|
|
|
- scheduleProfileImageView = avatar
|
|
|
|
|
- return chip
|
|
|
|
|
|
|
+ private func makeGoogleAuthButton() -> NSButton {
|
|
|
|
|
+ let button = NSButton(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 = .left
|
|
|
|
|
+ button.imageHugsTitle = false
|
|
|
|
|
+ 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
|
|
|
|
|
+ return button
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func makeScheduleRefreshButton() -> NSButton {
|
|
private func makeScheduleRefreshButton() -> NSButton {
|
|
@@ -2909,15 +2896,7 @@ private extension ViewController {
|
|
|
guard let self else { return }
|
|
guard let self else { return }
|
|
|
do {
|
|
do {
|
|
|
if self.googleOAuth.loadTokens() != nil {
|
|
if self.googleOAuth.loadTokens() != nil {
|
|
|
- try self.googleOAuth.signOut()
|
|
|
|
|
- await MainActor.run {
|
|
|
|
|
- self.updateGoogleAuthButtonTitle()
|
|
|
|
|
- self.applyGoogleProfile(nil)
|
|
|
|
|
- self.scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
|
|
- if let stack = self.scheduleCardsStack {
|
|
|
|
|
- self.renderScheduleCards(into: stack, meetings: [])
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ await MainActor.run { self.showGoogleAccountMenu() }
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2936,12 +2915,75 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ 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() {
|
|
private func updateGoogleAuthButtonTitle() {
|
|
|
let signedIn = (googleOAuth.loadTokens() != nil)
|
|
let signedIn = (googleOAuth.loadTokens() != nil)
|
|
|
- scheduleGoogleAuthButton?.title = signedIn ? "Logout" : "Login with Google"
|
|
|
|
|
- scheduleGoogleAuthButton?.image = NSImage(systemSymbolName: signedIn ? "rectangle.portrait.and.arrow.right" : "person.crop.circle.badge.checkmark", accessibilityDescription: "Google Auth")
|
|
|
|
|
- scheduleGoogleAuthButton?.imagePosition = .imageLeading
|
|
|
|
|
- scheduleGoogleAuthButton?.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
|
|
|
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
|
|
+ let iconLeftInset: CGFloat = 12
|
|
|
|
|
+
|
|
|
|
|
+ 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 + 74) // icon + extra left inset + cleaner 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 { paddedImage($0, iconSize: NSSize(width: 16, height: 16), leftInset: iconLeftInset) }
|
|
|
|
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
|
|
|
|
|
+ button.contentTintColor = palette.textPrimary
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if let g = NSImage(named: "GoogleGLogo") {
|
|
|
|
|
+ button.image = paddedImage(g, iconSize: NSSize(width: 16, height: 16), leftInset: iconLeftInset)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ button.image = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ button.contentTintColor = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ button.contentTintColor = signedIn ? palette.textPrimary : nil
|
|
|
|
|
+
|
|
|
|
|
+ let isDark = darkModeEnabled
|
|
|
|
|
+ button.layer?.backgroundColor = isDark ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1).cgColor : NSColor.white.cgColor
|
|
|
|
|
+ button.layer?.borderColor = (isDark ? NSColor(calibratedWhite: 0.50, alpha: 1) : NSColor(calibratedWhite: 0.72, alpha: 1)).cgColor
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
|
|
private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
|
|
@@ -2957,31 +2999,19 @@ private extension ViewController {
|
|
|
private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
|
scheduleProfileImageTask?.cancel()
|
|
scheduleProfileImageTask?.cancel()
|
|
|
scheduleProfileImageTask = nil
|
|
scheduleProfileImageTask = nil
|
|
|
|
|
+ scheduleCurrentProfile = profile
|
|
|
|
|
|
|
|
- guard let profile else {
|
|
|
|
|
- scheduleProfileContainer?.isHidden = true
|
|
|
|
|
- scheduleProfileNameLabel?.stringValue = "Google User"
|
|
|
|
|
- scheduleProfileEmailLabel?.stringValue = "Signed in"
|
|
|
|
|
- scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
|
|
- scheduleProfileImageView?.contentTintColor = palette.textSecondary
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- scheduleProfileContainer?.isHidden = false
|
|
|
|
|
- scheduleProfileNameLabel?.stringValue = profile.name
|
|
|
|
|
- scheduleProfileEmailLabel?.stringValue = profile.email
|
|
|
|
|
- scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
|
|
- scheduleProfileImageView?.contentTintColor = palette.textSecondary
|
|
|
|
|
|
|
+ updateGoogleAuthButtonTitle()
|
|
|
|
|
|
|
|
- guard let pictureURL = profile.pictureURL else { return }
|
|
|
|
|
|
|
+ guard let profile, let pictureURL = profile.pictureURL else { return }
|
|
|
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 {
|
|
await MainActor.run {
|
|
|
- self?.scheduleProfileImageView?.image = image
|
|
|
|
|
- self?.scheduleProfileImageView?.contentTintColor = nil
|
|
|
|
|
|
|
+ self?.scheduleGoogleAuthButton?.image = self?.paddedImage(image, iconSize: NSSize(width: 16, height: 16), leftInset: 12)
|
|
|
|
|
+ self?.scheduleGoogleAuthButton?.contentTintColor = nil
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
// Keep placeholder avatar if image fetch fails.
|
|
// Keep placeholder avatar if image fetch fails.
|
|
@@ -2989,6 +3019,31 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ 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 paddedImage(_ image: NSImage, iconSize: NSSize, leftInset: CGFloat) -> NSImage {
|
|
|
|
|
+ let canvasSize = NSSize(width: iconSize.width + leftInset, height: iconSize.height)
|
|
|
|
|
+ let result = NSImage(size: canvasSize)
|
|
|
|
|
+ result.lockFocus()
|
|
|
|
|
+ image.draw(in: NSRect(x: leftInset, y: 0, width: iconSize.width, height: iconSize.height),
|
|
|
|
|
+ from: NSRect(origin: .zero, size: image.size),
|
|
|
|
|
+ operation: .copy,
|
|
|
|
|
+ fraction: 1.0)
|
|
|
|
|
+ result.unlockFocus()
|
|
|
|
|
+ result.isTemplate = false
|
|
|
|
|
+ return result
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@MainActor
|
|
@MainActor
|
|
|
func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
|
|
func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
|
|
|
if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
|
|
if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
|