|
|
@@ -85,10 +85,8 @@ final class ViewController: NSViewController {
|
|
85
|
85
|
private weak var scheduleScrollRightButton: NSView?
|
|
86
|
86
|
private weak var scheduleFilterDropdown: NSPopUpButton?
|
|
87
|
87
|
private weak var scheduleGoogleAuthButton: NSButton?
|
|
88
|
|
- private weak var scheduleProfileContainer: NSView?
|
|
89
|
|
- private weak var scheduleProfileNameLabel: NSTextField?
|
|
90
|
|
- private weak var scheduleProfileEmailLabel: NSTextField?
|
|
91
|
|
- private weak var scheduleProfileImageView: NSImageView?
|
|
|
88
|
+ private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
|
|
|
89
|
+ private var scheduleCurrentProfile: GoogleProfileDisplay?
|
|
92
|
90
|
private var scheduleProfileImageTask: Task<Void, Never>?
|
|
93
|
91
|
|
|
94
|
92
|
/// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
|
|
|
@@ -932,6 +930,11 @@ private extension ViewController {
|
|
932
|
930
|
contentStack.spacing = 14
|
|
933
|
931
|
contentStack.alignment = .leading
|
|
934
|
932
|
|
|
|
933
|
+ contentStack.addArrangedSubview(scheduleTopAuthRow())
|
|
|
934
|
+ if let authRow = contentStack.arrangedSubviews.last {
|
|
|
935
|
+ contentStack.setCustomSpacing(20, after: authRow)
|
|
|
936
|
+ }
|
|
|
937
|
+
|
|
935
|
938
|
let joinActions = meetJoinActionsRow()
|
|
936
|
939
|
contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
|
|
937
|
940
|
contentStack.addArrangedSubview(meetJoinSectionRow())
|
|
|
@@ -1817,18 +1820,28 @@ private extension ViewController {
|
|
1817
|
1820
|
|
|
1818
|
1821
|
row.addArrangedSubview(makeScheduleRefreshButton())
|
|
1819
|
1822
|
|
|
1820
|
|
- let profileBadge = makeScheduleProfileBadge()
|
|
1821
|
|
- scheduleProfileContainer = profileBadge
|
|
1822
|
|
- row.addArrangedSubview(profileBadge)
|
|
|
1823
|
+ row.addArrangedSubview(makeScheduleFilterDropdown())
|
|
|
1824
|
+ row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
|
1825
|
+ return row
|
|
|
1826
|
+ }
|
|
|
1827
|
+
|
|
|
1828
|
+ private func scheduleTopAuthRow() -> NSView {
|
|
|
1829
|
+ let row = NSStackView()
|
|
|
1830
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1831
|
+ row.orientation = .horizontal
|
|
|
1832
|
+ row.alignment = .centerY
|
|
|
1833
|
+ row.spacing = 10
|
|
|
1834
|
+
|
|
|
1835
|
+ let spacer = NSView()
|
|
|
1836
|
+ spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1837
|
+ row.addArrangedSubview(spacer)
|
|
|
1838
|
+ spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
1823
|
1839
|
|
|
1824
|
|
- let connectButton = makeSchedulePillButton(title: googleOAuth.loadTokens() == nil ? "Login with Google" : "Logout")
|
|
1825
|
|
- connectButton.target = self
|
|
1826
|
|
- connectButton.action = #selector(scheduleConnectButtonPressed(_:))
|
|
1827
|
|
- scheduleGoogleAuthButton = connectButton
|
|
|
1840
|
+ let authButton = makeGoogleAuthButton()
|
|
|
1841
|
+ scheduleGoogleAuthButton = authButton
|
|
1828
|
1842
|
updateGoogleAuthButtonTitle()
|
|
1829
|
|
- row.addArrangedSubview(connectButton)
|
|
|
1843
|
+ row.addArrangedSubview(authButton)
|
|
1830
|
1844
|
|
|
1831
|
|
- row.addArrangedSubview(makeScheduleFilterDropdown())
|
|
1832
|
1845
|
row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
|
|
1833
|
1846
|
return row
|
|
1834
|
1847
|
}
|
|
|
@@ -1881,52 +1894,26 @@ private extension ViewController {
|
|
1881
|
1894
|
return button
|
|
1882
|
1895
|
}
|
|
1883
|
1896
|
|
|
1884
|
|
- private func makeScheduleProfileBadge() -> NSView {
|
|
1885
|
|
- let chip = roundedContainer(cornerRadius: 16, color: palette.inputBackground)
|
|
1886
|
|
- chip.translatesAutoresizingMaskIntoConstraints = false
|
|
1887
|
|
- styleSurface(chip, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
1888
|
|
- chip.heightAnchor.constraint(equalToConstant: 34).isActive = true
|
|
1889
|
|
- chip.widthAnchor.constraint(equalToConstant: 230).isActive = true
|
|
1890
|
|
- chip.isHidden = true
|
|
1891
|
|
-
|
|
1892
|
|
- let avatar = NSImageView()
|
|
1893
|
|
- avatar.translatesAutoresizingMaskIntoConstraints = false
|
|
1894
|
|
- avatar.wantsLayer = true
|
|
1895
|
|
- avatar.layer?.cornerRadius = 11
|
|
1896
|
|
- avatar.layer?.masksToBounds = true
|
|
1897
|
|
- avatar.imageScaling = .scaleAxesIndependently
|
|
1898
|
|
- avatar.widthAnchor.constraint(equalToConstant: 22).isActive = true
|
|
1899
|
|
- avatar.heightAnchor.constraint(equalToConstant: 22).isActive = true
|
|
1900
|
|
- avatar.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
1901
|
|
- avatar.contentTintColor = palette.textSecondary
|
|
1902
|
|
-
|
|
1903
|
|
- let name = textLabel("Google User", font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textPrimary)
|
|
1904
|
|
- let email = textLabel("Signed in", font: NSFont.systemFont(ofSize: 10, weight: .regular), color: palette.textMuted)
|
|
1905
|
|
- name.lineBreakMode = .byTruncatingTail
|
|
1906
|
|
- email.lineBreakMode = .byTruncatingTail
|
|
1907
|
|
- name.maximumNumberOfLines = 1
|
|
1908
|
|
- email.maximumNumberOfLines = 1
|
|
1909
|
|
-
|
|
1910
|
|
- let textStack = NSStackView(views: [name, email])
|
|
1911
|
|
- textStack.translatesAutoresizingMaskIntoConstraints = false
|
|
1912
|
|
- textStack.orientation = .vertical
|
|
1913
|
|
- textStack.spacing = 0
|
|
1914
|
|
- textStack.alignment = .leading
|
|
1915
|
|
-
|
|
1916
|
|
- chip.addSubview(avatar)
|
|
1917
|
|
- chip.addSubview(textStack)
|
|
1918
|
|
- NSLayoutConstraint.activate([
|
|
1919
|
|
- avatar.leadingAnchor.constraint(equalTo: chip.leadingAnchor, constant: 8),
|
|
1920
|
|
- avatar.centerYAnchor.constraint(equalTo: chip.centerYAnchor),
|
|
1921
|
|
- textStack.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 7),
|
|
1922
|
|
- textStack.trailingAnchor.constraint(equalTo: chip.trailingAnchor, constant: -8),
|
|
1923
|
|
- textStack.centerYAnchor.constraint(equalTo: chip.centerYAnchor)
|
|
1924
|
|
- ])
|
|
1925
|
|
-
|
|
1926
|
|
- scheduleProfileNameLabel = name
|
|
1927
|
|
- scheduleProfileEmailLabel = email
|
|
1928
|
|
- scheduleProfileImageView = avatar
|
|
1929
|
|
- return chip
|
|
|
1897
|
+ private func makeGoogleAuthButton() -> NSButton {
|
|
|
1898
|
+ let button = NSButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
|
|
|
1899
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
1900
|
+ button.isBordered = false
|
|
|
1901
|
+ button.bezelStyle = .regularSquare
|
|
|
1902
|
+ button.wantsLayer = true
|
|
|
1903
|
+ button.layer?.cornerRadius = 21
|
|
|
1904
|
+ button.layer?.borderWidth = 1
|
|
|
1905
|
+ button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
|
|
|
1906
|
+ button.imagePosition = .imageLeading
|
|
|
1907
|
+ button.alignment = .left
|
|
|
1908
|
+ button.imageHugsTitle = false
|
|
|
1909
|
+ button.lineBreakMode = .byTruncatingTail
|
|
|
1910
|
+ button.contentTintColor = palette.textPrimary
|
|
|
1911
|
+ button.imageScaling = .scaleNone
|
|
|
1912
|
+ button.heightAnchor.constraint(equalToConstant: 42).isActive = true
|
|
|
1913
|
+ let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
|
|
|
1914
|
+ widthConstraint.isActive = true
|
|
|
1915
|
+ scheduleGoogleAuthButtonWidthConstraint = widthConstraint
|
|
|
1916
|
+ return button
|
|
1930
|
1917
|
}
|
|
1931
|
1918
|
|
|
1932
|
1919
|
private func makeScheduleRefreshButton() -> NSButton {
|
|
|
@@ -2909,15 +2896,7 @@ private extension ViewController {
|
|
2909
|
2896
|
guard let self else { return }
|
|
2910
|
2897
|
do {
|
|
2911
|
2898
|
if self.googleOAuth.loadTokens() != nil {
|
|
2912
|
|
- try self.googleOAuth.signOut()
|
|
2913
|
|
- await MainActor.run {
|
|
2914
|
|
- self.updateGoogleAuthButtonTitle()
|
|
2915
|
|
- self.applyGoogleProfile(nil)
|
|
2916
|
|
- self.scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
2917
|
|
- if let stack = self.scheduleCardsStack {
|
|
2918
|
|
- self.renderScheduleCards(into: stack, meetings: [])
|
|
2919
|
|
- }
|
|
2920
|
|
- }
|
|
|
2899
|
+ await MainActor.run { self.showGoogleAccountMenu() }
|
|
2921
|
2900
|
return
|
|
2922
|
2901
|
}
|
|
2923
|
2902
|
|
|
|
@@ -2936,12 +2915,75 @@ private extension ViewController {
|
|
2936
|
2915
|
}
|
|
2937
|
2916
|
}
|
|
2938
|
2917
|
|
|
|
2918
|
+ private func showGoogleAccountMenu() {
|
|
|
2919
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
2920
|
+ let menu = NSMenu()
|
|
|
2921
|
+
|
|
|
2922
|
+ let name = scheduleCurrentProfile?.name ?? "Google account"
|
|
|
2923
|
+ let email = scheduleCurrentProfile?.email ?? "Signed in"
|
|
|
2924
|
+ let accountItem = NSMenuItem(title: "\(name) (\(email))", action: nil, keyEquivalent: "")
|
|
|
2925
|
+ accountItem.isEnabled = false
|
|
|
2926
|
+ menu.addItem(accountItem)
|
|
|
2927
|
+ menu.addItem(.separator())
|
|
|
2928
|
+
|
|
|
2929
|
+ let logoutItem = NSMenuItem(title: "Logout", action: #selector(scheduleLogoutSelected(_:)), keyEquivalent: "")
|
|
|
2930
|
+ logoutItem.target = self
|
|
|
2931
|
+ menu.addItem(logoutItem)
|
|
|
2932
|
+
|
|
|
2933
|
+ let point = NSPoint(x: 0, y: button.bounds.height + 2)
|
|
|
2934
|
+ menu.popUp(positioning: nil, at: point, in: button)
|
|
|
2935
|
+ }
|
|
|
2936
|
+
|
|
|
2937
|
+ @objc private func scheduleLogoutSelected(_ sender: NSMenuItem) {
|
|
|
2938
|
+ do {
|
|
|
2939
|
+ try googleOAuth.signOut()
|
|
|
2940
|
+ updateGoogleAuthButtonTitle()
|
|
|
2941
|
+ applyGoogleProfile(nil)
|
|
|
2942
|
+ scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
|
|
|
2943
|
+ if let stack = scheduleCardsStack {
|
|
|
2944
|
+ renderScheduleCards(into: stack, meetings: [])
|
|
|
2945
|
+ }
|
|
|
2946
|
+ } catch {
|
|
|
2947
|
+ showSimpleError("Couldn’t logout Google account.", error: error)
|
|
|
2948
|
+ }
|
|
|
2949
|
+ }
|
|
|
2950
|
+
|
|
2939
|
2951
|
private func updateGoogleAuthButtonTitle() {
|
|
2940
|
2952
|
let signedIn = (googleOAuth.loadTokens() != nil)
|
|
2941
|
|
- scheduleGoogleAuthButton?.title = signedIn ? "Logout" : "Login with Google"
|
|
2942
|
|
- scheduleGoogleAuthButton?.image = NSImage(systemSymbolName: signedIn ? "rectangle.portrait.and.arrow.right" : "person.crop.circle.badge.checkmark", accessibilityDescription: "Google Auth")
|
|
2943
|
|
- scheduleGoogleAuthButton?.imagePosition = .imageLeading
|
|
2944
|
|
- scheduleGoogleAuthButton?.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
|
2953
|
+ guard let button = scheduleGoogleAuthButton else { return }
|
|
|
2954
|
+ let iconLeftInset: CGFloat = 12
|
|
|
2955
|
+
|
|
|
2956
|
+ let profileName = scheduleCurrentProfile?.name ?? "Google account"
|
|
|
2957
|
+ let profileEmail = scheduleCurrentProfile?.email ?? "Sign in with Google"
|
|
|
2958
|
+ let title = signedIn ? "\(profileName) · \(profileEmail)" : "Sign in with Google"
|
|
|
2959
|
+ let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
|
|
|
2960
|
+ let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
|
|
|
2961
|
+ button.attributedTitle = NSAttributedString(string: title, attributes: [
|
|
|
2962
|
+ .font: titleFont,
|
|
|
2963
|
+ .foregroundColor: titleColor
|
|
|
2964
|
+ ])
|
|
|
2965
|
+ let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
|
|
|
2966
|
+ let idealWidth = ceil(textWidth + 74) // icon + extra left inset + cleaner side padding
|
|
|
2967
|
+ scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
|
|
|
2968
|
+
|
|
|
2969
|
+ if signedIn {
|
|
|
2970
|
+ let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
|
2971
|
+ button.image = symbol.flatMap { paddedImage($0, iconSize: NSSize(width: 16, height: 16), leftInset: iconLeftInset) }
|
|
|
2972
|
+ button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
|
|
|
2973
|
+ button.contentTintColor = palette.textPrimary
|
|
|
2974
|
+ } else {
|
|
|
2975
|
+ if let g = NSImage(named: "GoogleGLogo") {
|
|
|
2976
|
+ button.image = paddedImage(g, iconSize: NSSize(width: 16, height: 16), leftInset: iconLeftInset)
|
|
|
2977
|
+ } else {
|
|
|
2978
|
+ button.image = nil
|
|
|
2979
|
+ }
|
|
|
2980
|
+ button.contentTintColor = nil
|
|
|
2981
|
+ }
|
|
|
2982
|
+ button.contentTintColor = signedIn ? palette.textPrimary : nil
|
|
|
2983
|
+
|
|
|
2984
|
+ let isDark = darkModeEnabled
|
|
|
2985
|
+ 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
|
|
|
2986
|
+ button.layer?.borderColor = (isDark ? NSColor(calibratedWhite: 0.50, alpha: 1) : NSColor(calibratedWhite: 0.72, alpha: 1)).cgColor
|
|
2945
|
2987
|
}
|
|
2946
|
2988
|
|
|
2947
|
2989
|
private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
|
|
|
@@ -2957,31 +2999,19 @@ private extension ViewController {
|
|
2957
|
2999
|
private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
|
|
2958
|
3000
|
scheduleProfileImageTask?.cancel()
|
|
2959
|
3001
|
scheduleProfileImageTask = nil
|
|
|
3002
|
+ scheduleCurrentProfile = profile
|
|
2960
|
3003
|
|
|
2961
|
|
- guard let profile else {
|
|
2962
|
|
- scheduleProfileContainer?.isHidden = true
|
|
2963
|
|
- scheduleProfileNameLabel?.stringValue = "Google User"
|
|
2964
|
|
- scheduleProfileEmailLabel?.stringValue = "Signed in"
|
|
2965
|
|
- scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
2966
|
|
- scheduleProfileImageView?.contentTintColor = palette.textSecondary
|
|
2967
|
|
- return
|
|
2968
|
|
- }
|
|
2969
|
|
-
|
|
2970
|
|
- scheduleProfileContainer?.isHidden = false
|
|
2971
|
|
- scheduleProfileNameLabel?.stringValue = profile.name
|
|
2972
|
|
- scheduleProfileEmailLabel?.stringValue = profile.email
|
|
2973
|
|
- scheduleProfileImageView?.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
|
|
2974
|
|
- scheduleProfileImageView?.contentTintColor = palette.textSecondary
|
|
|
3004
|
+ updateGoogleAuthButtonTitle()
|
|
2975
|
3005
|
|
|
2976
|
|
- guard let pictureURL = profile.pictureURL else { return }
|
|
|
3006
|
+ guard let profile, let pictureURL = profile.pictureURL else { return }
|
|
2977
|
3007
|
scheduleProfileImageTask = Task { [weak self] in
|
|
2978
|
3008
|
do {
|
|
2979
|
3009
|
let (data, _) = try await URLSession.shared.data(from: pictureURL)
|
|
2980
|
3010
|
if Task.isCancelled { return }
|
|
2981
|
3011
|
guard let image = NSImage(data: data) else { return }
|
|
2982
|
3012
|
await MainActor.run {
|
|
2983
|
|
- self?.scheduleProfileImageView?.image = image
|
|
2984
|
|
- self?.scheduleProfileImageView?.contentTintColor = nil
|
|
|
3013
|
+ self?.scheduleGoogleAuthButton?.image = self?.paddedImage(image, iconSize: NSSize(width: 16, height: 16), leftInset: 12)
|
|
|
3014
|
+ self?.scheduleGoogleAuthButton?.contentTintColor = nil
|
|
2985
|
3015
|
}
|
|
2986
|
3016
|
} catch {
|
|
2987
|
3017
|
// Keep placeholder avatar if image fetch fails.
|
|
|
@@ -2989,6 +3019,31 @@ private extension ViewController {
|
|
2989
|
3019
|
}
|
|
2990
|
3020
|
}
|
|
2991
|
3021
|
|
|
|
3022
|
+ private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
|
|
|
3023
|
+ let result = NSImage(size: size)
|
|
|
3024
|
+ result.lockFocus()
|
|
|
3025
|
+ image.draw(in: NSRect(origin: .zero, size: size),
|
|
|
3026
|
+ from: NSRect(origin: .zero, size: image.size),
|
|
|
3027
|
+ operation: .copy,
|
|
|
3028
|
+ fraction: 1.0)
|
|
|
3029
|
+ result.unlockFocus()
|
|
|
3030
|
+ result.isTemplate = false
|
|
|
3031
|
+ return result
|
|
|
3032
|
+ }
|
|
|
3033
|
+
|
|
|
3034
|
+ private func paddedImage(_ image: NSImage, iconSize: NSSize, leftInset: CGFloat) -> NSImage {
|
|
|
3035
|
+ let canvasSize = NSSize(width: iconSize.width + leftInset, height: iconSize.height)
|
|
|
3036
|
+ let result = NSImage(size: canvasSize)
|
|
|
3037
|
+ result.lockFocus()
|
|
|
3038
|
+ image.draw(in: NSRect(x: leftInset, y: 0, width: iconSize.width, height: iconSize.height),
|
|
|
3039
|
+ from: NSRect(origin: .zero, size: image.size),
|
|
|
3040
|
+ operation: .copy,
|
|
|
3041
|
+ fraction: 1.0)
|
|
|
3042
|
+ result.unlockFocus()
|
|
|
3043
|
+ result.isTemplate = false
|
|
|
3044
|
+ return result
|
|
|
3045
|
+ }
|
|
|
3046
|
+
|
|
2992
|
3047
|
@MainActor
|
|
2993
|
3048
|
func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
|
|
2994
|
3049
|
if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
|